The node-engine owns the composition it alone runs (H1, H2)

ADR 0593 §1 and §7: compose, its bus reader and runtime reader, takein,
validate, the overlay generator, the composer's run and mesh-compose, and
the judgement of a move leave mesh-sdk for the node-engine's internal
packages, so no other module composes and a change to them rebuilds only
the node-engine. The shapes others read (the composer's request and
verdict, the overlay's names, compositions and answers) stay in mesh-sdk.
The node's grant is now a golden file of what the grants part composes,
since the composer of the bus's user list leaves mesh-sdk too.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 18:24:05 +01:00
parent 98fbc490a4
commit 9a12f2851f
218 changed files with 33022 additions and 54 deletions
+582
View File
@@ -0,0 +1,582 @@
package frombus
import (
"context"
"errors"
"fmt"
"maps"
"regexp"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
)
// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
// nothing; the proposal and the revision are the candidate's, said back in the answer.
//
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the
// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer {
a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision}
refuse := func(err error) delivery.CandidateAnswer {
a.Refused = err.Error()
a.Verdict = delivery.CandidateRefused
return a
}
trunk, err := r.Placements(ctx)
if err != nil {
return refuse(fmt.Errorf("frombus: placement: %w", err))
}
if s.Running == nil {
s.Running = running
}
// Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and
// below, a module the composition refuses, each composed as a stub so the rest is composed and compared.
failed := map[string]string{}
in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil)
if err != nil {
return refuse(err)
}
asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
if err != nil {
return refuse(err)
}
// What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the
// whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is
// assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet
// issued under Added (review of L2b, round 1, finding 1).
runs := map[string]bool{}
for _, c := range running {
if c.Module != "" {
runs[c.Module] = true
}
}
var consumed []compose.CredentialKey
for _, k := range asConsumer {
if runs[k.ConsumerModule] {
consumed = append(consumed, k)
}
}
asConsumer = consumed
if len(asConsumer)+len(asProvider) > 0 {
if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err))
}
}
composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps))
if err != nil {
// Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's.
if len(failed) == 0 {
return refuse(err)
}
failed[delivery.HaveNodeWord] = err.Error()
composed, skipped, stood = nil, nil, nil
}
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
// names it (the node agent's own composer hands it on that want alone), so a candidate does not add it.
runsOwn := false
for _, c := range running {
runsOwn = runsOwn || c.Module == ""
}
if !runsOwn {
for key, c := range composed {
if c.Module == "" {
delete(composed, key)
}
}
}
if _, whole := failed[delivery.HaveNodeWord]; whole {
// Nothing composed to compare: every refusal is said, and nothing else.
for _, m := range sortedKeys(failed) {
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
}
heldAsToday(&a, s.HeldToday)
a.Verdict = delivery.VerdictOf(a)
return a
}
// A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from
// the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531).
if len(s.InOwn) > 0 {
var judged []delivery.Composed
for _, c := range running {
if c.Module == "" || !slices.Contains(s.InOwn, c.Module) {
judged = append(judged, c)
}
}
running = judged
}
cmp := delivery.CompareComposedWith(composed, running, s.SameValue)
// The answer rides in the node's have key: its shown bodies are bounded, the rest named only.
budget := MaxShown
for i := range cmp.Differs {
kept := cmp.Differs[i].Bodies[:0]
for _, b := range cmp.Differs[i].Bodies {
if n := len(b.Composed) + len(b.Running); n <= budget {
budget -= n
kept = append(kept, b)
} else {
kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why})
a.Note = "some differing resources are named without their bodies: the answer holds at most " +
fmt.Sprint(MaxShown) + " bytes of them"
}
}
cmp.Differs[i].Bodies = kept
}
// A refused module was composed as a stub: what it composed is no difference; its refusal is said.
notFailed := func(m string) bool { _, ok := failed[m]; return !ok }
a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) })
a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) })
a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) })
cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) })
skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
// What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node.
added := map[string]bool{}
for node, p := range s.Placements {
if p == nil {
continue
}
was := map[string]bool{}
for _, m := range trunk[node].Modules {
was[m.Module] = true
}
for _, m := range p.Modules {
if !was[m.Module] {
added[node+"/"+m.Module] = true
}
}
}
unissued := map[string][]string{}
refusals := map[string][]string{}
for _, x := range stood {
node, module := standsInFor(x, s.Node)
switch {
case node == s.Node && runs[module]:
// A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is
// empty, and every module counts as the group's).
refusals[module] = append(refusals[module], x)
case module != "" && added[node+"/"+module]:
if node == s.Node {
unissued[module] = append(unissued[module], x)
} // another node's addition: the module here that carries it differs, which the assigner judges
case module == "" || node != s.Node:
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x)
default:
refusals[module] = append(refusals[module], x)
}
}
addedHere := map[string]delivery.ModuleAdded{}
for _, m := range cmp.Added {
addedHere[m.Module] = m
}
for _, m := range skipped { // placed, and no build given to compose it at
addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
}
for m, xs := range unissued {
got := addedHere[m]
got.Module = m
got.Unissued = append(got.Unissued, xs...)
addedHere[m] = got
}
for _, m := range sortedKeys(addedHere) {
a.Added = append(a.Added, addedHere[m])
}
for _, m := range sortedKeys(refusals) {
if _, isAdded := addedHere[m]; isAdded {
continue // a module the candidate adds here: what it lacks is said under Added
}
if _, isFailed := failed[m]; isFailed {
continue // said below, as why it could not be composed
}
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
}
for _, m := range sortedKeys(failed) {
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
}
sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module })
heldAsToday(&a, s.HeldToday)
a.Verdict = delivery.VerdictOf(a)
return a
}
// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how:
// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue
// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No
// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered.
func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
if len(today) == 0 {
return
}
kept := a.Refusals[:0]
for _, r := range a.Refusals {
if w, keep := today[r.Module]; keep {
r.Today = w
a.Held = append(a.Held, r)
} else {
kept = append(kept, r)
}
}
a.Refusals = kept
if len(a.Refusals) == 0 {
a.Refusals = nil
}
}
// maxCulprits bounds the modules one refusal is tried on, each a whole composition.
const maxCulprits = 8
// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies).
const MaxShown = 256 << 10
var (
grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`)
// grantOfStandIn is the provision a grant's stand-in is of.
grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`)
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
)
// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port
// `port module:port`, a grant `the grant of <provision> for <module> on <node>`, a need `<module> on <node> needs ...`;
// the module empty when the words name none (the certificate, the authority).
func standsInFor(x, node string) (string, string) {
if m := grantStandIn.FindStringSubmatch(x); m != nil {
return m[2], m[1]
}
if m := needsStandIn.FindStringSubmatch(x); m != nil {
return m[2], m[1]
}
if rest, ok := strings.CutPrefix(x, "port "); ok {
if module, _, ok := strings.Cut(rest, ":"); ok {
return node, module
}
}
if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") {
return node, module
}
return node, ""
}
// composeEach is the one rule, for the node that applies and for its answer to a candidate alike, by which a module's
// own refusal refuses that module and never the node (issue 1126: seed-8 fixed it in the answer alone, and the
// applying path still refused all of novox for de-spiegel's unset route names). The node is composed; a refusal is laid
// on the module whose stub (compose.Stub) changes it (culprit), that module is held (compose.Node.Held) and said in
// failed with the refusal's words, and the rest is composed again, until it composes or the refusal is no single
// module's, which is answered as the node's (err). Applied composes as the node applies (compose.Reading) and holds the
// module placed here each stand-in is for; one for no module placed here (the node's own, a value this node holds for a
// module elsewhere) refuses the node (compose.ErrStoodIn). Not applied, it composes in compose.Foreseeing and answers
// every stand-in for the caller to judge (Answer).
//
// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it
// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others
// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own
// composition is in out: the node keeps what it last applied.
//
// **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held
// route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer,
// a collector, the provider of what the held one contributes to and the filter where it listens each compose with what
// the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the
// build it runs where one is known (ran: a composition of its own running at another build than it is composed at),
// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision
// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account,
// and the hold says so.
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string,
ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) {
mode := compose.Foreseeing
if applied {
mode = compose.Reading
}
runs := map[string]*delivery.Composed{}
for i := range running {
if m := running[i].Module; m != "" {
runs[m] = &running[i]
}
}
src := map[string]catalogue.Manifest{} // each held module's manifest before it was held, offering what it last offered
left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut)
hold := func(m, why, leave string) {
if _, done := failed[m]; !done {
failed[m] = why // a held module tried again keeps the words it was first held for
}
if _, ok := src[m]; !ok {
man, ok := manifestOn(in, node, m)
if !ok {
return
}
if ran != nil {
if was, ok := ran(m); ok {
man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens
}
}
src[m] = man
}
if leave != "" {
if left[m] == nil {
left[m] = map[string]bool{}
}
left[m][leave] = true
if leave != leaveAll && leave != leaveOwn {
failed[m] += "; composed without what it contributes to " + leave + ", which does not compose"
}
}
in = heldAs(in, node, m, src[m], left[m], runs[m])
}
for _, m := range sortedKeys(failed) {
hold(m, failed[m], "")
}
for {
out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode)
if err != nil {
module, leave := culprit(in, node, fps, mode, failed, err)
if module == "" {
return nil, nil, nil, firstReason(err, failed)
}
hold(module, err.Error(), leave)
continue
}
again := false
if applied && len(stood) > 0 {
placed := map[string]bool{}
for _, m := range assignedOn(in, node) {
placed[m] = true
}
of := map[string][]string{}
var nodes []string
for _, x := range stood {
if n, m := standsInFor(x, node); n == node && placed[m] {
of[m] = append(of[m], x)
} else {
nodes = append(nodes, x)
}
}
for _, m := range sortedKeys(of) {
if _, done := failed[m]; !done {
hold(m, "stands in for "+strings.Join(of[m], ", "), "")
again = true
continue
}
// Already held, and still a stand-in in another module's composition: a grant not yet issued for what
// it contributes (a module held for its own secret keeps its contributions, leaveOwn). Its contribution
// to that provision is left out, so the provider never applies a stand-in (issue 774, review round 1).
for _, x := range of[m] {
if g := grantOfStandIn.FindStringSubmatch(x); g != nil && !left[m][g[1]] {
hold(m, failed[m], g[1])
again = true
}
}
}
if !again && len(nodes) > 0 {
return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", "))
}
}
if !again {
break
}
}
skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held })
return out, skipped, stood, nil
}
// firstReason is err, a refusal no culprit is found for, led by the first reason of each held module it names: a held
// module's stand-in refused later never replaces why it was held (issue 774, 2026-10-30: mesh-self-check on ace held for
// "has no bus credential", then refused as "runs as its own account and declares no own secret broker", its bare stub's
// words, which were all the proposal's refusal said).
func firstReason(err error, failed map[string]string) error {
var named []string
for _, m := range sortedKeys(failed) {
if strings.Contains(err.Error(), m) {
named = append(named, m+" is held: "+failed[m])
}
}
if len(named) == 0 {
return err
}
return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err)
}
// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The
// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is
// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it).
func blames(m string, again, err error) bool {
if m == catalogue.RuntimeModule {
// Or where its stub fails only for the modules of their own account it would run (issue 774): they are held
// after it, each for its own words, and the node composes the rest.
var none *catalogue.NoRuntimeToRunError
return again == nil || errors.As(again, &none) && none.Held
}
return again == nil || again.Error() != err.Error()
}
// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets
// alone, its offer kept; any other word leaves out what it contributes to that provision alone.
//
// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774,
// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its
// login while it is held.
const (
leaveAll = "*"
leaveOwn = "own-secrets"
)
// assignedOn is the modules placed on node, in placement order.
func assignedOn(in compose.Input, node string) []string {
for _, n := range in.Nodes {
if n.Name == node {
return n.Assigned
}
}
return nil
}
// manifestOn is the manifest node composes module from: its Kept, else the catalogue's.
func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) {
for _, n := range in.Nodes {
if n.Name == node {
if man, ok := n.Kept[module]; ok {
return man, true
}
}
}
man, ok := in.Catalogue[module]
return man, ok
}
// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the
// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's
// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its
// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not
// one module's). Modules already refused are passed over for their stub.
func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) {
assigned := assignedOn(in, node)
// Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a
// refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's
// answer took 7m46s).
var order []string
for _, m := range assigned {
if strings.Contains(err.Error(), m) && len(order) < maxCulprits {
order = append(order, m)
}
}
changes := func(m, leave string) bool {
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
return blames(m, again, err)
}
for _, m := range order {
if _, done := failed[m]; done {
continue
}
if changes(m, "") {
return m, ""
}
}
for _, m := range order {
man, ok := manifestOn(in, node, m)
if !ok {
continue
}
// One already held is tried again too: held for its build, its stub may still refuse (its own secret never
// issued, issue 1126). A provision named in the error is tried first, each at one more composition.
var to []string
for p := range man.Contributes {
to = append(to, p)
}
for p := range man.ContributesMany {
if _, both := man.Contributes[p]; !both {
to = append(to, p)
}
}
sort.SliceStable(to, func(i, j int) bool {
return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j])
})
for _, p := range to {
if changes(m, p) {
return m, p
}
}
if isBare(man) {
continue
}
if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) {
return m, leaveOwn
}
if changes(m, leaveAll) {
return m, leaveAll
}
}
return "", ""
}
// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll).
func isBare(m catalogue.Manifest) bool {
return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0
}
// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node
// composes it from, leave left out of its offer: a culprit's try.
func stubbed(in compose.Input, node, module, leave string) compose.Input {
man, ok := manifestOn(in, node, module)
if !ok {
return in
}
var left map[string]bool
if leave != "" {
left = map[string]bool{leave: true}
}
return heldAs(in, node, module, man, left, nil)
}
// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of
// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of
// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes
// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's
// repository on a node it never ran on) is no refusal of the rest.
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input {
out := in
out.Nodes = append([]compose.Node(nil), in.Nodes...)
for i := range out.Nodes {
if out.Nodes[i].Name != node {
continue
}
if left[leaveAll] {
man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil
} else if len(left) > 0 {
if left[leaveOwn] {
man.OwnSecrets = nil
}
contributes := map[string]map[string]any{}
for p, v := range man.Contributes {
if !left[p] {
contributes[p] = v
}
}
many := maps.Clone(man.ContributesMany)
for p := range left {
delete(many, p)
}
man.Contributes, man.ContributesMany = contributes, many
}
kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1)
for k, v := range out.Nodes[i].Kept {
kept[k] = v
}
kept[module] = compose.Carried(man, module, runs)
out.Nodes[i].Kept = kept
held := make(map[string]bool, len(out.Nodes[i].Held)+1)
for k, v := range out.Nodes[i].Held {
held[k] = v
}
held[module] = true
out.Nodes[i].Held = held
}
return out
}
@@ -0,0 +1,595 @@
package frombus
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/overlay"
)
// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only;
// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos,
// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs.
func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
ctx := context.Background()
image := "alpine@sha256:" + strings.Repeat("a", 64)
manifest := func(module string) []byte {
// Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused
// "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory).
return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` +
`"own-secrets":{"token":"${dir:state}/token"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` +
`{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` +
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
}
lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) }
mk := func(placed ...string) *bus {
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
for _, m := range placed {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
}
b := &bus{placements: map[string]delivery.Placement{"n1": p},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}},
manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs},
builds: map[string]*delivery.BuilderBuild{}}
for _, m := range []string{"web", "api", "spiegel"} {
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
}
for _, m := range []string{"web", "api", "spiegel"} {
raw := manifest(m)
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
// web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of
// the new form at all.
for i, m := range []string{"web", "api"} {
mod := b.modules[m]
mod.FP2 = fp(string(rune('1' + i)))
b.modules[m] = mod
b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
}
return b
}
given := func(b *bus) map[string]*delivery.Placement {
p := b.placements["n1"]
return map[string]*delivery.Placement{"n1": &p}
}
// Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
b := mk("lemurs", "web", "api", "spiegel")
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
var refused []string
for _, r := range a.Refusals {
refused = append(refused, r.Module)
}
if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a)
}
for _, r := range a.Refusals {
want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site",
"spiegel": "spiegel has not been built for this mesh"}[r.Module]
if !strings.Contains(r.Why, want) {
t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want)
}
}
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
}
if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK {
t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a)
}
// Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every
// refused module held today the verdict is no refusal, and each is named under Held.
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
t.Errorf("held as today: %+v", today)
}
// A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused.
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
t.Errorf("held as today in part: %+v", part)
}
// No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold.
if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
{Module: "mailu", Why: "no build"}}}); true {
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 {
t.Errorf("held as today spreads: %+v", a)
}
}
// The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
// they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
b = mk("lemurs", overlay.Name, "spiegel")
p := b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p
fps := map[string]string{"lemurs": fp("e")}
stub := Scope{Node: "n1"}
in, err := Input(ctx, b, Mesh{}, stub)
if err != nil {
t.Fatal(err)
}
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
base, _, err := compose.Compositions(in, "n1", fps, compose.Reading)
if err != nil {
t.Fatal(err)
}
// A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's
// answer took 7m46s, one compose per module), though here a stub would compose.
if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" {
t.Errorf("a refusal naming no placed module was laid on %s", m)
}
ownKey, _ := delivery.ComposedNodeKey("n1")
own := base[ownKey]
carriedRes := []json.RawMessage{
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
// They run after the node's first own resource.
own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...)
var running []delivery.Composed
for k, c := range base {
if k == ownKey {
c = own
}
running = append(running, c)
}
scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}
got, _, held, err := Compose(ctx, b, Mesh{}, scope, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err)
}
gotOwn, _ := json.Marshal(got[ownKey].Resources)
wantOwn, _ := json.Marshal(own.Resources)
if string(gotOwn) != string(wantOwn) {
t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
}
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
if a.Verdict != delivery.CandidateSame {
t.Errorf("the fold's answer: %+v, want the same", a)
}
// (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
// module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
}
// (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
// the same, not 'in what runs, not composed' (seed #531, novox).
membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`)
withMembership := make([]delivery.Composed, 0, len(running))
for _, c := range running {
if c.Module == "" {
c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership)
}
withMembership = append(withMembership, c)
}
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame {
t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
}
// Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
if _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil ||
!strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") {
t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held)
}
// (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
// opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
// stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file
// a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export.
listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` +
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` +
`{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` +
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`)
vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` +
`"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`)
b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault")
for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} {
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
p = b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
for i := range p.Modules {
if p.Modules[i].Module == "spiegel" {
p.Modules[i].Ports = map[string]int{"8080": 20005}
}
}
b.placements["n1"] = p
rec := b.records["n1"]
rec.OutwardLinks = []string{"eth0"}
b.records["n1"] = rec
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")}
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
t.Fatal(err)
}
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
t.Fatal(err)
}
keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`)
running = nil
for _, c := range base {
switch c.Module {
case "":
c.Resources = append(append([]json.RawMessage{}, c.Resources...),
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:`+strings.Repeat("c", 64)+`","ports":["10.77.0.1:20005:8080"]}`),
json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`))
case "vault":
c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile)
}
running = append(running, c)
}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatalf("the node does not compose itself with spiegel carried: %v", err)
}
var rules string
var keptComposed json.RawMessage
for _, c := range got {
for _, raw := range c.Resources {
var r struct {
ID string `json:"id"`
Content string `json:"content"`
}
_ = json.Unmarshal(raw, &r)
switch r.ID {
case "nft." + catalogue.FilteringID():
rules = r.Content
case "vault." + catalogue.KeptID():
keptComposed = raw
}
}
}
if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") {
t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules)
}
// The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue
// 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and
// files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools'
// MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is
// composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container).
for _, c := range running {
if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) {
t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths)
}
}
f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json")
if err != nil {
t.Fatal(err)
}
f1, err := catalogue.Decode(f1raw)
if err != nil {
t.Fatal(err)
}
f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{
json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`),
json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`),
json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`),
json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}}
if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) {
t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want)
}
// Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node:
// "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it
// contributes to other modules' collections still reaches them.
for _, row := range []struct {
name string
handed *catalogue.Handed
items []catalogue.Item
}{
{"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"},
Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}},
{"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}},
} {
kept := f1
kept.Handed, kept.Items = row.handed, row.items
st := compose.Stub(kept)
if st.Handed != nil && st.Handed.Templates != nil {
t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates)
}
if !reflect.DeepEqual(st.Items, row.items) {
t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items)
}
if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) {
t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items)
}
}
var k1, k2 any
_ = json.Unmarshal(keptComposed, &k1)
_ = json.Unmarshal(keptFile, &k2)
if !reflect.DeepEqual(k1, k2) {
t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile)
}
// (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs
// de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values
// for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing
// praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is
// refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread
// to mailu (its smtp provider) and proxy (its route provider), and from them on. Neither is held now: proxy composes
// without de-spiegel's routes, which do not compose, and mailu with its smtp account, which does.
spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json")
if err != nil {
t.Fatal(err)
}
proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"}],` +
`"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`)
mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` +
`"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`)
b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel")
for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} {
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
p = b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")}
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
refused = nil
for _, r := range a.Refusals {
refused = append(refused, r.Module)
if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) {
t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why)
}
}
if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) {
t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a)
}
for _, m := range []string{"lemurs", "mailu", "proxy"} {
if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) {
t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a)
}
}
// (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It
// composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was
// marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which
// receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it
// removes nothing of it.
spiegelRuns := []json.RawMessage{
json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`),
json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`),
json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
t.Fatal(err)
}
in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])}
in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest {
m.Contributes, m.ContributesMany = nil, nil
return m
}(in.Nodes[0].Kept["de-spiegel"])
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
t.Fatal(err)
}
running = nil
for _, c := range base {
if c.Module == "" {
c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...)
}
running = append(running, c)
}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps)
if err != nil {
t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err)
}
if len(held) != 1 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["de-spiegel"], "without what it contributes to route") {
t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held)
}
for _, m := range []string{"lemurs", "mailu", "proxy"} {
if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m {
t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got))
}
}
carries := func(c delivery.Composed, what string) {
t.Helper()
ownRes, _ := json.Marshal(c.Resources)
for _, raw := range spiegelRuns {
var r struct{ ID string }
_ = json.Unmarshal(raw, &r)
if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) {
t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes)
}
}
}
carries(got[ownKey], "folded")
// (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form,
// so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried
// in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its
// container, files and units (issue 1945 by a new path).
fps["de-spiegel"] = fp("7")
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") {
t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held)
}
spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"])
if _, ok := got[spiegelKey]; ok {
t.Errorf("held de-spiegel's own composition is handed")
}
carries(got[ownKey], "leaving the fold")
// (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others
// compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own
// secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which
// reads what site listens on, composes with it (issue 1960: a hold never spreads).
site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` +
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` +
`{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`)
b = mk("lemurs", overlay.Name, "site", "nft")
for m, raw := range map[string][]byte{"site": site, "nft": nft} {
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
p = b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
for i := range p.Modules {
if p.Modules[i].Module == "site" {
p.Modules[i].Ports = map[string]int{"8080": 20005}
}
}
b.placements["n1"] = p
rec = b.records["n1"]
rec.OutwardLinks = []string{"eth0"}
b.records["n1"] = rec
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")}
siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{
json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`),
json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"])
if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" {
t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held)
}
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 20005") {
t.Errorf("the filter drops held site's opening where it runs (20005): %s", rules)
}
for key, c := range got {
for _, raw := range c.Resources {
if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) {
t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw)
}
}
}
// (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and
// app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres
// consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them").
provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1))
app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` +
`"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`)
b2 := mk("lemurs", overlay.Name, "site", "nft", "app")
for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} {
b2.manifests[delivery.ManifestDigest(raw)] = raw
b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
p2 := b2.placements["n1"]
p2.Decided.Endpoint = "n1.example:51820"
for i := range p2.Modules {
if p2.Modules[i].Module == "site" {
p2.Modules[i].Ports = map[string]int{"8080": 20005}
}
}
b2.placements["n1"] = p2
b2.records["n1"] = rec
b2.held = true
fps["app"] = fp("9")
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
appKey, _ := delivery.ComposedKey("n1", "app", fps["app"])
if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed {
t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held)
}
if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") {
t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound)
}
delete(fps, "app")
// A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's
// routes, its names unset, leave the proxy without them, and its smtp account stays with mailu.
withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{},
Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}}
feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}},
OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}}
for _, row := range []struct {
left map[string]bool
route, smtp, own bool
}{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} {
k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"]
if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own {
t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets)
}
}
// (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its
// manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the
// build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build
// that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090.
moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1))
b.manifests[delivery.ManifestDigest(moved)] = moved
b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)}
for i := range p.Modules {
p.Modules[i].Ports = nil
}
b.placements["n1"] = p
siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`)
siteRuns.FP = fp("7")
b.manifests[delivery.ManifestDigest(site)] = site
b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
if err != nil || held["site"] == "" || held["nft"] != "" {
t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held)
}
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 8080") {
t.Errorf("the filter drops what held site last offered (8080): %s", rules)
}
for key, c := range got {
for _, raw := range c.Resources {
if strings.Contains(string(raw), "dport 9090") {
t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw)
}
}
}
}
// A module another node cannot run in this composition offers nothing in it: the refusal names it and why
// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a
// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module
// (ace took 7m46s).
func otherNodeNotResolvedIsNamed(t *testing.T) {
ctx := context.Background()
prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`)
cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`)
// n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here.
b := &bus{placements: map[string]delivery.Placement{
"n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
"n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}},
manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}}
given := map[string]*delivery.Placement{}
for n, p := range b.placements {
p := p
given[n] = &p
}
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
t.Errorf("a refusal for want of n2's provider = %+v", a)
}
}
// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered
// "reaches no artifact store", and nothing said why).
func noStoreReachedSaysWhy(t *testing.T) {
in := compose.Input{Catalogue: map[string]catalogue.Manifest{
"distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" {
t.Errorf("said %q", got)
}
in.Nodes = in.Nodes[:1]
if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" {
t.Errorf("said %q", got)
}
}
+112
View File
@@ -0,0 +1,112 @@
package frombus
import (
"fmt"
"sort"
"strconv"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
)
// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export`
// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay
// stable for the same store, since adopt checks its digest again at the warrant.
// What the machine observes (its record) is not in it.
func Export(in compose.Input, node string) (delivery.Placement, error) {
var n *compose.Node
for i := range in.Nodes {
if in.Nodes[i].Name == node {
n = &in.Nodes[i]
}
}
if n == nil {
return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node)
}
// At the version a placement key is read at, with a list of modules even when none is placed: the export is the
// seed's value, and the reader refuses a value of version 0 or without its modules.
p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{
Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address,
PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount,
AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}}
// The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted
// one whether or not the hub took it; and the names a person gave the hub's carried peers.
if t, ok := in.Tunnels[node]; ok {
rt := recordTunnelOf(t.Tunnel)
p.Decided.Tunnel = &rt
} else if in.Adopted != nil && in.Adopted.Hub == node {
rt := recordTunnelOf(in.Adopted.Tunnel)
p.Decided.Tunnel = &rt
}
if n.Place.Hub {
for _, c := range in.CarriedPeers {
if c.Named != "" {
if p.Decided.PeerNames == nil {
p.Decided.PeerNames = map[string]string{}
}
p.Decided.PeerNames[c.PublicKey] = c.Named
}
}
}
for _, module := range n.Assigned {
m := delivery.PlacedModule{Module: module}
for port, at := range n.Ports[module] {
if m.Ports == nil {
m.Ports = map[string]int{}
}
m.Ports[strconv.Itoa(port)] = at
}
for provision, to := range n.Bound[module] {
if m.Bindings == nil {
m.Bindings = map[string]delivery.Provider{}
}
m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
}
// A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with
// the placement. A module in the config form has mesh-config's values laid into its layer, which are not.
settingsForm := !in.Catalogue[module].DeclaresConfig()
for _, layer := range n.Settings[module] {
if layer.From == catalogue.MeshWideLayer {
m.HasMesh, m.MeshWide = true, layer.Placement
if settingsForm && len(layer.Values) > 0 {
m.MeshValues = layer.Values
}
} else {
m.HasLayer, m.Layer = true, layer.Placement
if settingsForm && len(layer.Values) > 0 {
m.Values = layer.Values
}
}
}
p.Modules = append(p.Modules, m)
}
for provision, to := range n.Pins {
if p.Pins == nil {
p.Pins = map[string]delivery.Provider{}
}
p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
}
for _, h := range in.Holdings {
if h.Node == node {
p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
}
}
sort.SliceStable(p.Holds, func(i, j int) bool {
if p.Holds[i].Claim != p.Holds[j].Claim {
return p.Holds[i].Claim < p.Holds[j].Claim
}
return p.Holds[i].Module < p.Holds[j].Module
})
return p, nil
}
func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel {
out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
for _, peer := range t.Peers {
out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address})
}
return out
}
File diff suppressed because it is too large Load Diff
+869
View File
@@ -0,0 +1,869 @@
package frombus
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
"slices"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/overlay"
)
// bus is a Reader over values held in memory, counting whose config values were read.
type bus struct {
vaultAuthority string
handed map[string]bool
placements map[string]delivery.Placement
records map[string]delivery.NodeRecord
modules map[string]delivery.Module
manifests map[string][]byte
values map[string]map[string]any // by node.module
valuesRead []string
own []compose.OwnSecretRow
ownErr error
// asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming
// module its placement does not put on the node.
asked []compose.CredentialKey
// builds are the builder's keys, by "<module> <fp>"; archives the artifact store's blobs, by digest, and fetched
// every URL asked; served another node's values by "<node>.<module>", and servedRead every ask with its keys.
builds map[string]*delivery.BuilderBuild
archives map[string][]byte
fetched []string
served map[string]map[string]any
servedRead []string
buildsRead []string
// held answers every credential asked as held, sealed to the consumer.
held bool
}
func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
b.buildsRead = append(b.buildsRead, module)
return b.builds[module+" "+fp], nil
}
func (b *bus) Archive(_ context.Context, url, digest string) ([]byte, error) {
b.fetched = append(b.fetched, url)
body, ok := b.archives[digest]
if !ok {
return nil, errors.New("the store holds no such blob")
}
return body, nil
}
func (b *bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
b.servedRead = append(b.servedRead, node+"."+module+":"+strings.Join(keys, ","))
out := map[string]any{}
for _, k := range keys {
if v, ok := b.served[node+"."+module][k]; ok {
out[k] = v
}
}
return out, nil
}
func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) {
return b.placements, nil
}
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.manifests[digest]
if !ok {
return nil, errors.New("no such manifest")
}
return raw, nil
}
func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
return b.own, b.ownErr
}
func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
b.asked = append(b.asked, asConsumer...)
for _, k := range asConsumer {
placed := false
for _, m := range b.placements[node].Modules {
placed = placed || m.Module == k.ConsumerModule
}
if !placed {
return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node)
}
}
var out []compose.Credential
var issued []compose.Issued
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
if b.held {
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
}
}
return out, issued, nil
}
func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) {
b.valuesRead = append(b.valuesRead, node+"."+module)
return b.values[node+"."+module], nil
}
func publicKey(t *testing.T) string {
t.Helper()
k, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
}
// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the
// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input
// composes; a node reads only its own config values; what cannot be composed is refused, naming it.
func TestInput(t *testing.T) {
// Issue 1126, the seed's shadow: every refusal named, an old-form module left to the node, and no store reached
// saying why (answer_rows_test.go).
t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode)
t.Run("no store reached says why", noStoreReachedSaysWhy)
// Issue 774: a node refused whole says what it held on the way.
t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld)
t.Run("a refused manifest never ends the read", aRefusedManifestNeverEndsTheRead)
t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes)
t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild)
t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads)
t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode)
t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed)
ctx := context.Background()
xterm, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json")
if err != nil {
t.Fatal(err)
}
digest := delivery.ManifestDigest(xterm)
// A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its
// layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it.
media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` +
`"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` +
`"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`)
mediaDigest := delivery.ManifestDigest(media)
keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)}
caps := []string{"container-runtime", "systemd", "wireguard"}
tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
mk := func() *bus {
return &bus{
placements: map[string]delivery.Placement{
"anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1",
Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}},
Modules: []delivery.PlacedModule{{Module: overlay.Name}},
Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}},
"laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
Modules: []delivery.PlacedModule{{Module: overlay.Name},
{Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}},
{Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true,
Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}},
},
records: map[string]delivery.NodeRecord{
"anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2},
"laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"},
},
modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}},
manifests: map[string][]byte{digest: xterm, mediaDigest: media},
values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"},
"laptop.media": {"lines": "not mesh-config's"}},
own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
}
}
// What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it.
var private catalogue.Manifest
raw, _ := json.Marshal(overlay.Manifest())
if err := json.Unmarshal(raw, &private); err != nil {
t.Fatal(err)
}
parsed, err := catalogue.Decode(xterm)
if err != nil {
t.Fatal(err)
}
parsedMedia, err := catalogue.Decode(media)
if err != nil {
t.Fatal(err)
}
capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true}
tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
want := compose.Input{
Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia},
Nodes: []compose.Node{
{Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true,
Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true},
{Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here",
Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet,
Ports: map[string]map[int]int{"xterm": {80: 8080}},
Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}},
"media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}},
{From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}},
},
Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}},
OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
// The same value as a core credential's box, for a core module (ADR 0349 §16).
KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}},
Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}},
Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true},
// The controller's order is by address; laptop enrolled under its peer's key, and a person named the other.
Authority: "the mesh's certificate",
CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"},
{PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}},
}
b := mk()
got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
gj, _ := json.MarshalIndent(got, "", " ")
wj, _ := json.MarshalIndent(want, "", " ")
t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj)
}
// L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's
// and the vault's; with none, the controller's alone, as above.
signing := mk()
signing.vaultAuthority = "the vault's certificate"
if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil ||
bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" {
t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err)
}
// Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's.
judged := mk()
judged.handed = map[string]bool{"media": true, "xterm": false}
if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil ||
!reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil {
t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err)
}
if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead)
}
// The same composer over both: the compositions from the bus are the store's, byte for byte.
fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64),
"media": "src2:" + strings.Repeat("c", 64)}
fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading)
if err != nil {
t.Fatal(err)
}
if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) {
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
}
// Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one
// thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves
// here. Placed puts the same placement over the store's input unchanged.
running := make([]delivery.Composed, 0, len(fromBus))
for _, c := range fromBus {
running = append(running, c)
}
laptop := mk().placements["laptop"]
asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
t.Errorf("the candidate equal to the placement: %+v", c)
}
moved := mk().placements["anchor"]
moved.Decided.Endpoint = "anchor.example:51821"
changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name)
}
if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
t.Errorf("nothing runs: %+v", c)
}
// ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not
// yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes.
keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`)
lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
// A provider and a consumer of a pair credential on the one node (compose's objects and files).
objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1",
Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}})
files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}})
one := func(placed ...string) *bus {
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
for _, m := range placed {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
}
return &bus{placements: map[string]delivery.Placement{"n1": p},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)},
"objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}},
manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs,
delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files},
own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}}
}
oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64),
"objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)}
runs, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
var runsNow []delivery.Composed
for _, c := range runs {
runsNow = append(runsNow, c)
}
objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
var consumerRuns []delivery.Composed
for _, c := range objectsRun {
consumerRuns = append(consumerRuns, c)
}
withoutOwn := func(cs []delivery.Composed) []delivery.Composed {
var out []delivery.Composed
for _, c := range cs {
if c.Module != "" {
out = append(out, c)
}
}
return out
}
// What the controller runs on n1 for a module no want names (lemurs, at a build of the old form) and the private
// network's (built by nobody): its composed writer (mesh-controller compositionsOf) folds their resources into the
// node's own, each in its place, its module taken off; the rest split by module.
tunnelled := func() *bus {
b := one("keeper", "lemurs", overlay.Name)
p := b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p
return b
}
tunnelledAs := func() map[string]*delivery.Placement {
p := tunnelled().placements["n1"]
return map[string]*delivery.Placement{"n1": &p}
}
folded := func() []delivery.Composed {
b := tunnelled()
in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
if err != nil {
t.Fatal(err)
}
reading := in
reading.Nodes = append([]compose.Node(nil), in.Nodes...)
reading.Nodes[0].ReadsModules = true
d, _, err := compose.ComposeIn(reading, "n1", compose.Reading)
if err != nil {
t.Fatal(err)
}
body, err := d.Body()
if err != nil {
t.Fatal(err)
}
var envelope struct {
Resources []map[string]json.RawMessage `json:"resources"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
var resources []json.RawMessage
for _, r := range envelope.Resources {
var module string
_ = json.Unmarshal(r["module"], &module)
if module == "lemurs" || module == overlay.Name {
delete(r, "module")
}
raw, _ := json.Marshal(r)
resources = append(resources, raw)
}
split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{})
if err != nil {
t.Fatal(err)
}
var out []delivery.Composed
for _, c := range split {
out = append(out, c)
}
return out
}()
if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) {
t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name)
}
placedAs := func(modules ...string) map[string]*delivery.Placement {
p := one(modules...).placements["n1"]
return map[string]*delivery.Placement{"n1": &p}
}
for name, c := range map[string]struct {
b *bus
given map[string]*delivery.Placement
running []delivery.Composed
verdict string
check func(delivery.CandidateAnswer) bool
}{
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame,
func(a delivery.CandidateAnswer) bool {
return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7
}},
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame,
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers,
func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
"one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool {
for _, m := range a.Added {
if m.Module == "keeper" {
return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0
}
}
return false
}},
"a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password")
}},
// Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an
// ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued.
"a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers,
func(a delivery.CandidateAnswer) bool {
return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0
}},
// And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition.
"the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0
}},
"cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused,
func(a delivery.CandidateAnswer) bool { return a.Refused != "" }},
// Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want
// names and the controller folds into the node's own, are folded there again: the node's own is the same, and
// neither is added.
"mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(),
folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
} {
fps := oneFps
if name == "mesh-wireguard and an old-form module, folded" {
fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network
}
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
if a.Verdict != c.verdict || !c.check(a) {
t.Errorf("%s: %+v", name, a)
}
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw)
}
}
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("Placed with the node's own placement changes the input: %v", err)
}
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err)
}
if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil {
t.Error("Placed a node the input holds no node for")
}
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil {
t.Error("a given placement naming another node was taken")
}
// ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
// placement the bus holds, and read back it is the store's input again.
for node, placed := range mk().placements {
got, err := Export(want, node)
if err != nil {
t.Fatal(err)
}
if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) {
pj, _ := delivery.EncodePlacement(placed)
t.Errorf("%s exported as %s, want %s", node, gj, pj)
}
}
if _, err := Export(want, "stranger"); err == nil {
t.Error("a node the input lacks was exported")
}
b = mk()
if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil {
t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets)
}
if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
// Every node's placed modules, and nothing placed nowhere: anchor places no xterm.
t.Errorf("the check read %v", b.valuesRead)
}
// A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node
// with no secret composed nothing while the vault did not answer); one whose module does, refuses without it.
quiet := mk()
quiet.ownErr = errors.New("no responders")
if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil {
t.Errorf("anchor, with no own secret, needed the vault: %v", err)
}
if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil {
t.Error("laptop's media, which declares an own secret, composed without the vault")
}
// A module the node's want keeps at another build than the trunk's composes from that build's manifest, resolved with
// what it made (issue 1126), on the composing node alone.
kept := mk()
oldXterm := []byte(`{"module":"xterm","version":"1","resources":[{"id":"tools","type":"process","name":"xterm-tools",` +
`"artifact":"tools","run":["./xterm-tools"]}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/xterm-tools","binary":"xterm-tools"}]}}`)
kept.manifests["old"] = oldXterm
oldFP := "src2:" + strings.Repeat("9", 64)
toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64)
kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old",
Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}}
parsedOld, err := catalogue.Decode(oldXterm)
if err != nil {
t.Fatal(err)
}
keptBuilt, err := parsedOld.Resolve([]catalogue.Built{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}})
if err != nil {
t.Fatal(err)
}
in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": oldFP}})
if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": keptBuilt}) || in.Nodes[0].Kept != nil {
t.Errorf("the want's build is kept on the composing node alone: %v, %v", in.Nodes, err)
}
// Issue 1126, the seed's refusals. restic and the provisioner's client as the catalogue holds them (mesh-catalog
// c05aa5e6), restic moved with an own secret the vault leaves out (it hands it to the client, mesh-vault ownnode.go
// handed): composed from the indexer's manifest restic is not handed and stands in for that secret, as novox's,
// ace's, g14's and shanks's answers refused; composed from its build, it is handed, its templates archive read
// where the node reaches the artifact store, and the composition is the one the controller's take-in gives.
resticRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.module.json")
if err != nil {
t.Fatal(err)
}
clientRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json")
if err != nil {
t.Fatal(err)
}
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
`"serves":{"artifact-store":{"port":5000}}}`)
texts := map[string]string{}
var packed bytes.Buffer
zw := gzip.NewWriter(&packed)
tw := tar.NewWriter(zw)
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
text, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.files/" + f)
if err != nil {
t.Fatal(err)
}
texts[f] = string(text)
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(text); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
archiveDigest := delivery.ManifestDigest(packed.Bytes())
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
"store": "src2:" + strings.Repeat("5", 64)}
resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}}
clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}
movedMesh := func(built bool) *bus {
b := one()
p := b.placements["n1"]
p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}
p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator"
b.placements["n1"] = p
rec := b.records["n1"]
rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"}
b.records["n1"] = rec
b.modules = map[string]delivery.Module{}
b.manifests = map[string][]byte{}
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} {
b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)}
b.manifests[delivery.ManifestDigest(raw)] = raw
}
// The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out.
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
if built {
b.builds = map[string]*delivery.BuilderBuild{
"restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts},
"mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts},
"store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
}
b.archives = map[string][]byte{archiveDigest: packed.Bytes()}
}
return b
}
if out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) {
t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held)
} else {
for key, c := range out {
if c.Module == "restic" {
// Its stub's composition, applied, would remove what restic runs: a held module keeps its last one.
t.Errorf("held restic is composed as %s, want none of its own composition handed", key)
}
}
}
builtBus := movedMesh(true)
fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatalf("restic composed from its build: %v", err)
}
if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) {
t.Errorf("the templates archive was read at %v", builtBus.fetched)
}
resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"])
if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret
t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled)
}
// A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8).
unreferenced := movedMesh(true)
unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]}
if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") {
t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held)
}
// A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done
// build of it is at the trunk's FP2: its build is not read.
folding := movedMesh(true)
foldIn, err := Input(ctx, folding, Mesh{}, Scope{Node: "n1", InOwn: []string{"mesh-provisioner-client"}})
if err != nil {
t.Fatal(err)
}
clientParsed, err := catalogue.Decode(clientRaw)
if err != nil {
t.Fatal(err)
}
if slices.Contains(folding.buildsRead, "mesh-provisioner-client") || !reflect.DeepEqual(foldIn.Catalogue["mesh-provisioner-client"], clientParsed) {
t.Errorf("a folded module was taken from its build: builds read %v", folding.buildsRead)
}
// The controller's: its take-in resolves each manifest with the key's artifacts and the archive's texts, and composes
// from those over the same input.
storeIn, err := Input(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"})
if err != nil {
t.Fatal(err)
}
for module, built := range map[string][]catalogue.Built{
"restic": {{Name: "tools", Kind: "bundle", Reference: resticArtifacts[0].Reference, Digest: digestOf("6")},
{Name: "files", Kind: "archive", Reference: resticArtifacts[1].Reference, Digest: archiveDigest, Texts: texts}},
"mesh-provisioner-client": {{Name: "client", Kind: "bundle", Reference: clientArtifacts[0].Reference, Digest: digestOf("7")}},
} {
resolved, err := storeIn.Catalogue[module].Resolve(built)
if err != nil {
t.Fatal(err)
}
// Kept as its bytes and read again, as the controller's store holds a build's manifest.
raw, _ := json.Marshal(resolved)
if storeIn.Catalogue[module], err = catalogue.Decode(raw); err != nil {
t.Fatal(err)
}
}
storeIn.OwnSecrets = movedMesh(true).own
fromStoreMoved, _, err := compose.Compositions(storeIn, "n1", movedFps, compose.Reading)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(fromBuild, fromStoreMoved) {
gj, _ := json.MarshalIndent(fromBuild, "", " ")
wj, _ := json.MarshalIndent(fromStoreMoved, "", " ")
t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj)
}
// ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no
// secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets
// (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one.
noSecrets := movedMesh(true)
noSecrets.own = nil
seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
if err != nil {
t.Fatalf("the plans from the bus: %v", err)
}
controllers, err := plansIn(storeIn, "n1", compose.Foreseeing)
if err != nil {
t.Fatal(err)
}
if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) {
gj, _ := json.MarshalIndent(seat, "", " ")
wj, _ := json.MarshalIndent(controllers, "", " ")
t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj)
}
// ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures.
items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
if err != nil || len(unplaced) > 0 || len(items) == 0 {
t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err)
}
for module, its := range items {
for _, it := range its {
if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" {
t.Errorf("%s's item %+v is not placed", module, it)
}
}
}
// And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for
// mesh-provisioner and disk-load, the lead 2026-10-29).
viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) {
for _, body := range noSecrets.archives {
return body, nil
}
return nil, errors.New("no archive")
}), Mesh{}, Scope{Node: "n1"})
if err != nil || !reflect.DeepEqual(viaStates, items) {
t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items)
}
// And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused
// '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served.
mailer := []byte(`{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` +
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`)
withMailer := func(set bool) *bus {
b := mk()
p := b.placements["anchor"]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"})
b.placements["anchor"] = p
b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)}
b.manifests[delivery.ManifestDigest(mailer)] = mailer
if set {
b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}}
}
return b
}
if _, _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) {
t.Errorf("a provider's served setting nobody sets: %v", err)
}
served := withMailer(true)
if _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 {
t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held)
}
if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") {
t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead)
}
for name, c := range map[string]struct {
change func(*bus)
scope Scope
says string
}{
"no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
"no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"},
"a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
"a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"},
} {
b := mk()
c.change(b)
if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
}
}
}
// mapStates are read states by name, key to value, as a module's runtime gives them.
type mapStates map[string]map[string][]byte
func (m mapStates) Get(state, key string) ([]byte, bool, error) {
v, ok := m[state][key]
return v, ok, nil
}
func (m mapStates) Keys(state string) ([]string, error) {
var out []string
for k := range m[state] {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
// statesOf is b laid out as the states StatesReader reads.
func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
t.Helper()
out := mapStates{}
put := func(state, key string, raw []byte, err error) {
if err != nil {
t.Fatal(err)
}
if out[state] == nil {
out[state] = map[string][]byte{}
}
out[state][key] = raw
}
placements, _ := b.Placements(ctx)
for _, p := range placements {
values, err := delivery.PlacementValues(p)
for k, raw := range values {
put(PlacementRead, k, raw, err)
}
}
records, _ := b.Records(ctx)
for node, r := range records {
k, _ := delivery.HaveNodeKey(node)
rec := r
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
put(HaveRead, k, raw, err)
}
modules, _ := b.Modules(ctx)
for name, m := range modules {
raw, err := delivery.EncodeModule(m)
put(ModulesRead, name, raw, err)
var mf struct{ Module string }
_ = json.Unmarshal(b.manifests[m.Manifest], &mf)
k, _ := delivery.ManifestsKey(m.Manifest)
raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])})
put(ManifestsRead, k, raw, err)
}
for key, build := range b.builds {
module, fp, _ := strings.Cut(key, " ")
k, _ := delivery.BuilderBuildKey(module, fp)
raw, err := delivery.EncodeBuilderBuild(*build)
put(BuildsRead, k, raw, err)
}
for nm, values := range b.values {
for k, v := range values {
value, _ := json.Marshal(v)
raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule})
put(ResolvedRead, nm+"."+k, raw, err)
}
}
return out
}
@@ -0,0 +1,183 @@
// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the
// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR
// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog
// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more.
package frombustest
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"embed"
"encoding/json"
"errors"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/frombus"
)
//go:embed testdata
var testdata embed.FS
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
type Bus struct {
Placement map[string]delivery.Placement
Record map[string]delivery.NodeRecord
Module map[string]delivery.Module
Manifests map[string][]byte
Value map[string]map[string]any // by node.module
Own []compose.OwnSecretRow
Builds map[string]*delivery.BuilderBuild // by "<module> <fp>"
Archives map[string][]byte // by digest
Serves map[string]map[string]any // another node's values, by node.module
Held bool
}
var _ frombus.Reader = (*Bus)(nil)
func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) {
return b.Placement, nil
}
func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil }
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
func (b *Bus) VaultAuthority(context.Context) string { return "" }
func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.Manifests[digest]
if !ok {
return nil, errors.New("no such manifest")
}
return raw, nil
}
func (b *Bus) Values(_ context.Context, node, module string) (map[string]any, error) {
return b.Value[node+"."+module], nil
}
func (b *Bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.Own, nil }
func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
var out []compose.Credential
var issued []compose.Issued
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
if b.Held {
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
}
}
return out, issued, nil
}
func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
return b.Builds[module+" "+fp], nil
}
func (b *Bus) Archive(_ context.Context, _, digest string) ([]byte, error) {
body, ok := b.Archives[digest]
if !ok {
return nil, errors.New("the store holds no such blob")
}
return body, nil
}
func (b *Bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
out := map[string]any{}
for _, k := range keys {
if v, ok := b.Serves[node+"."+module][k]; ok {
out[k] = v
}
}
return out, nil
}
// Node is the one node of the mesh Provisioned composes.
const Node = "n1"
// Provisioned is one node, n1, that runs the artifact store, a postgres providing postgres-database, the provisioner's
// client and restic: restic built and handed to the provisioner, and requiring postgres-database. Its plans (frombus.Plans
// with Scope{Node: Node}) hand restic, whose plan names that provision with postgres on n1 as its provider.
func Provisioned() (*Bus, error) {
read := func(name string) ([]byte, error) { return testdata.ReadFile("testdata/" + name) }
resticRaw, err := read("catalogue-c05aa5e6-restic.module.json")
if err != nil {
return nil, err
}
var restic map[string]any
if err := json.Unmarshal(resticRaw, &restic); err != nil {
return nil, err
}
restic["requires"] = []string{"postgres-database"}
if resticRaw, err = json.Marshal(restic); err != nil {
return nil, err
}
clientRaw, err := read("catalogue-c05aa5e6-mesh-provisioner-client.module.json")
if err != nil {
return nil, err
}
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
`"serves":{"artifact-store":{"port":5000}}}`)
postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` +
`"serves":{"postgres-database":{"port":5432}}}`)
var packed bytes.Buffer
zw := gzip.NewWriter(&packed)
tw := tar.NewWriter(zw)
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
text, err := read("catalogue-c05aa5e6-restic.files/" + f)
if err != nil {
return nil, err
}
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
return nil, err
}
if _, err := tw.Write(text); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
archive := delivery.ManifestDigest(packed.Bytes())
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
fps := FPs()
b := &Bus{
Placement: map[string]delivery.Placement{Node: {Node: Node,
Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
Module: map[string]delivery.Module{}, Manifests: map[string][]byte{},
Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
Builds: map[string]*delivery.BuilderBuild{
"restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{
{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}},
"mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw),
Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle",
Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}},
"store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
"postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)},
},
Archives: map[string][]byte{archive: packed.Bytes()},
Held: true,
}
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} {
b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)}
b.Manifests[delivery.ManifestDigest(raw)] = raw
}
return b, nil
}
// FPs are the fingerprints Provisioned's modules are built at.
func FPs() map[string]string {
return map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
"store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)}
}
@@ -0,0 +1,272 @@
package frombustest
import (
"context"
"reflect"
"strings"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose/frombus"
"github.com/novox/mesh-host/internal/compose/fromruntime"
)
// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the
// provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node
// (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld
// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer
// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33,
// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node
// it is not placed on holds nothing.
func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
fp := "src2:" + strings.Repeat("9", 64)
for _, row := range []struct {
name string
bad string // the indexer's manifest of a module "backup" placed on n1; empty places none
// built is the manifest backup's done build names; empty gives it no build
built string
why string // words backup's hold says
// elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held.
elsewhere bool
// refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds
// backup too, and never ends the read.
refused string
// dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build,
// an item composition refuses), not one only a plan meets.
dataHeld bool
// dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item.
dataOnly bool
}{
{name: "every module composes"},
{name: "a built manifest that cannot be used",
bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`,
built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true},
{name: "a plan that refuses",
bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` +
`"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"},
{name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`,
refused: `"shows" is none of them`, dataHeld: true},
// ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read.
{name: "a data item its composition refuses",
bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`,
why: "nope", dataHeld: true, dataOnly: true},
{name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true,
refused: `"shows" is none of them`},
} {
b, err := Provisioned()
if err != nil {
t.Fatal(err)
}
if row.bad != "" {
if !row.elsewhere {
p := b.Placement[Node]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"})
b.Placement[Node] = p
}
digest := delivery.ManifestDigest([]byte(row.bad))
b.Manifests[digest] = []byte(row.bad)
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest}
if row.refused != "" {
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused,
Problems: []string{row.refused}}
}
if row.built != "" {
builtDigest := delivery.ManifestDigest([]byte(row.built))
b.Manifests[builtDigest] = []byte(row.built)
b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest,
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
}
if row.dataOnly {
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 {
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
}
continue
}
plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
switch {
case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")):
t.Errorf("%s: Plans did not hold the refused manifest alone: %v %v", row.name, plans, err)
case row.refused == "" && err == nil && !row.elsewhere:
t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name)
}
}
if row.why != "" || row.bad == "" {
// The node's own composition holds by the same rule: every other module composed, the bad one named.
fps := FPs()
if row.bad != "" {
fps["backup"] = fp
}
out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
if err != nil {
t.Fatalf("%s: the node's composition: %v", row.name, err)
}
if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" {
t.Errorf("%s: restic is not composed (held %v)", row.name, held)
}
if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) {
t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why)
}
}
plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
if err != nil {
t.Fatalf("%s: %v", row.name, err)
}
restic, handed := plans["restic"]
if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" ||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
}
// Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each,
// and where the time went is said by phase.
keptAt := FPs()
at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) ||
!reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) {
t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held)
}
if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") {
t.Errorf("%s: the phases are said as %q", row.name, said)
}
if row.bad == "" || row.elsewhere {
if len(held) > 0 {
t.Errorf("%s: held %v", row.name, held)
}
continue
}
if why, ok := held["backup"]; !ok || !strings.Contains(why, row.why) || len(held) != 1 {
t.Errorf("%s: held %v, want backup alone, saying %q", row.name, held, row.why)
}
if _, planned := plans["backup"]; planned {
t.Errorf("%s: the held module has a plan", row.name)
}
// The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole.
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
// A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file.
mustHold := row.dataHeld
if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil {
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
}
}
// ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is
// asked, and a manifest once per digest.
byKey := map[string]int{}
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
byKey[state+" watched"]++
return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
}, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil })
w := fromruntime.Watched{WatchedStates: ws}
for range 2 {
_, _, _ = w.Get(frombus.BuildsRead, "restic."+fp)
_, _, _ = w.Get(frombus.ManifestsRead, "sha256-d")
if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found {
t.Error("the watched reader does not answer the placement from its copy")
}
}
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 ||
byKey[frombus.PlacementRead+" watched"] != 1 {
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey)
}
fetched := 0
keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil })
for range 2 {
if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" {
t.Fatalf("the kept fetch answered %q, %v", body, err)
}
}
if fetched != 1 {
t.Errorf("a templates archive was fetched %d times, want once", fetched)
}
// Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again
// and the plans are the same.
b, err := Provisioned()
if err != nil {
t.Fatal(err)
}
reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) {
fetched++
_, digest, _ := strings.Cut(url, "/blobs/")
return b.Archives[digest], nil
}), fetched
var first map[string]catalogue.Plan
for i := range 2 {
plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node})
if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") {
t.Fatalf("composed %v (held %v): %v", plans, held, err)
}
first = plans
}
if fetched-archives != 1 {
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
}
}
// statesOf is the bus b as a module's read states hold it.
func statesOf(t *testing.T, b *Bus) frombus.States {
t.Helper()
s := states{}
put := func(state, key string, raw []byte, err error) {
if err != nil {
t.Fatal(err)
}
if s[state] == nil {
s[state] = map[string][]byte{}
}
s[state][key] = raw
}
for node, p := range b.Placement {
raw, err := delivery.EncodePlacement(p)
put(frombus.PlacementRead, "placement."+node, raw, err)
}
for node, rec := range b.Record {
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
put(frombus.HaveRead, node+".node", raw, err)
}
for module, m := range b.Module {
key, _ := delivery.ModulesKey(module)
raw, err := delivery.EncodeModule(m)
put(frombus.ModulesRead, key, raw, err)
}
for digest, man := range b.Manifests {
key, _ := delivery.ManifestsKey(digest)
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
put(frombus.ManifestsRead, key, raw, err)
}
for at, build := range b.Builds {
module, fp, _ := strings.Cut(at, " ")
key, _ := delivery.BuilderBuildKey(module, fp)
raw, err := delivery.EncodeBuilderBuild(*build)
put(frombus.BuildsRead, key, raw, err)
}
return s
}
// states are read states in memory.
type states map[string]map[string][]byte
func (s states) Get(state, key string) ([]byte, bool, error) {
v, ok := s[state][key]
return v, ok, nil
}
func (s states) Keys(state string) ([]string, error) {
var keys []string
for k := range s[state] {
keys = append(keys, k)
}
return keys, nil
}
func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok }
// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item
// with attributes its bar places by none of.
const unusableItem = `{"module":"backup","version":"1","moved":true,` +
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
`"items":[{"collection":"contribution:node-bar:block",` +
`"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}`
@@ -0,0 +1,60 @@
{
"module": "mesh-provisioner-client",
"version": "1",
"claims": [
{
"name": "node-provisioner",
"scope": "node"
}
],
"own-secrets": {
"provisioner-broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-provisioner-client",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-provisioner-client",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-provisioner-client"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-provisioner-client",
"place": ".",
"unknown": "program"
},
{
"id": "client",
"type": "process",
"name": "mesh-provisioner-client",
"artifact": "client",
"run": [
"./mesh-provisioner-client"
],
"env": {
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
"MESH_PROVISIONER_STATE": "${dir:state}"
},
"user": "mesh-provisioner-client",
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "client",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-provisioner-client",
"binary": "mesh-provisioner-client"
}
]
}
}
@@ -0,0 +1,2 @@
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
${backup:lines}
@@ -0,0 +1,2 @@
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
${backup:data}
@@ -0,0 +1,6 @@
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
daily=${config:keep-daily}
weekly=${config:keep-weekly}
monthly=${config:keep-monthly}
hand=${config:keep-hand-taken-days}
@@ -0,0 +1,199 @@
{
"module": "restic",
"version": "1",
"moved": true,
"claims": [
{
"name": "node-backup",
"scope": "node",
"serves": [
"backed-up",
"now",
"restore"
]
}
],
"emits": [
"backed-up"
],
"tools": [
"backup_health",
"backup_delete_retired",
"backup_deleted"
],
"own-secrets": {
"repository": "${dir:state}/repository.secret"
},
"data": {
"own": [
{
"id": "repository",
"path": "${dir:repository}",
"class": "rebuildable",
"backup": "none",
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
},
{
"id": "state",
"path": "${dir:state}",
"class": "cache",
"why": "what the last nights and measurements were; the next night writes it again"
}
]
},
"config": {
"keep-daily": {
"type": "number",
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 14
},
"keep-weekly": {
"type": "number",
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 8
},
"keep-monthly": {
"type": "number",
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 6
},
"keep-hand-taken-days": {
"type": "number",
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 7
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": ".",
"unknown": "program"
},
{
"id": "repository",
"type": "directory",
"mode": "0700",
"unknown": "program"
},
{
"id": "declared",
"type": "file",
"path": "${dir:state}/backups.conf",
"mode": "0600",
"template": "files/backups.conf"
},
{
"id": "data-declared",
"type": "file",
"path": "${dir:state}/data.conf",
"mode": "0600",
"template": "files/data.conf"
},
{
"id": "retention",
"type": "file",
"path": "${dir:state}/retention.conf",
"mode": "0644",
"trusted": true,
"template": "files/retention.conf"
},
{
"id": "tool",
"type": "package",
"package": "restic"
},
{
"id": "sqlite",
"type": "package",
"package": "sqlite"
},
{
"id": "night",
"type": "process",
"name": "restic-night",
"artifact": "tools",
"run": [
"./restic-backups",
"night"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "tool",
"tool": "backup_health",
"interval": "60s",
"timeout": "20s",
"looks": 2,
"grace": "60s"
}
},
{
"id": "measure",
"type": "process",
"name": "restic-measure",
"artifact": "tools",
"run": [
"./restic-backups",
"measure"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/restic-backups",
"binary": "restic-backups",
"loads": [
"restic-backups"
],
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
+298
View File
@@ -0,0 +1,298 @@
package frombus
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
)
// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given.
func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
t.Helper()
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}}
fps := map[string]string{}
for i, raw := range manifests {
module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: module})
b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))}
b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw)
fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64)
}
b.placements = map[string]delivery.Placement{"n1": p}
return b, fps
}
// Issue 774, 2026-10-30 19:30: a node refused whole after holding a module says what it held and why, since the last
// refusal can be of the held module's stub (node-tools held, then "its build produced 0 bundle(s)", its own why unsaid).
func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) {
refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}`
needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}`
b, fps := aNode(t, refused, needs)
refuse(b, "power", `"shows" is none of them`)
_, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) ||
!strings.Contains(err.Error(), `"shows" is none of them`) {
t.Errorf("the node refused whole with %v (held %v), want lamp's refusal and power held before, with its why", err, held)
}
}
// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1).
func refuse(b *bus, module string, problems ...string) {
m := b.modules[module]
m.Verdict, m.Problems = delivery.VerdictRefused, problems
b.modules[module] = m
}
// ADR 0578 §3: the bus reader never ends a whole read on a refused manifest. Input, Plans and DataItems compose the rest
// and hold the refused module, named with the indexer's words in Refused; a field the decoder does not know is set aside
// and named, and its module composed.
func aRefusedManifestNeverEndsTheRead(t *testing.T) {
ok := `{"module":"lamp","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/lamp","mode":"0700"}],` +
`"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`
bad := `{"module":"power","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/power","mode":"0700"}],` +
`"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`
later := `{"module":"later","version":"1","a-field-from-later":1}`
b, _ := aNode(t, ok, bad, later)
refuse(b, "power", "removes a stream it may not")
ctx := context.Background()
in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
if err != nil {
t.Fatalf("one refused manifest ended the read: %v", err)
}
if why := in.Refused["power"]; !strings.Contains(why, "refused the manifest of power: removes a stream it may not") ||
len(in.Refused) != 1 {
t.Fatalf("refused: %v", in.Refused)
}
if aside := in.SetAside["later"]; !strings.Contains(aside, "a-field-from-later") {
t.Fatalf("set aside: %v", in.SetAside)
}
if !in.Catalogue["power"].Stubbed || in.Catalogue["lamp"].Stubbed || in.Catalogue["later"].Module != "later" {
t.Fatalf("the catalogue: %+v", in.Catalogue)
}
if _, err := Plans(ctx, b, Mesh{}, Scope{Node: "n1"}); err != nil {
t.Fatalf("plans: %v", err)
}
items, _, held, err := DataItemsHeld(ctx, b, Mesh{}, Scope{Node: "n1"})
if err != nil || len(items["lamp"]) != 1 || items["power"] != nil || !strings.Contains(held["power"], "removes a stream") {
t.Fatalf("data items %v held %v: %v", items, held, err)
}
}
// Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub
// composes, since its stub always fails otherwise.
func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
err := errors.New("lamp refuses")
for _, row := range []struct {
module string
again error
blamed bool
}{
{"lamp", nil, true},
{"lamp", errors.New("other"), true},
{"lamp", errors.New("lamp refuses"), false},
{catalogue.RuntimeModule, nil, true},
{catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
{catalogue.RuntimeModule, fmt.Errorf("compose: %w", &catalogue.NoRuntimeToRunError{Held: true}), true},
{catalogue.RuntimeModule, &catalogue.NoRuntimeToRunError{}, false},
} {
if got := blames(row.module, row.again, err); got != row.blamed {
t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed)
}
}
}
// Issue 774, 2026-10-30 19:30: the trunk's node-tools moved to a build not yet done, and ace, g14 and shanks composed
// it from the indexer's manifest, which names no bundle: "its build produced 0 bundle(s)", every module refused. A
// module that runs is now held until its build lands, the runtime composed as what runs of it; and a module wanted at
// another build than the trunk's is composed from the build its want names.
func aRunningModuleWaitsForItsBuild(t *testing.T) {
dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64)
running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{
json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`),
json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` +
dg("1") + `","digest":"` + dg("1") + `","path":"` + catalogue.BundlePath("node-tools", "runtime") + `"}`)}}}
for _, row := range []struct {
name string
want string // the build node-tools' want names
done bool // the trunk's build is done
held string // words node-tools' hold says; empty: composed
digest string // the runtime bundle's digest composed
}{
{name: "the trunk's build not done yet", want: next, held: "no done build yet"},
{name: "wanted at the build that runs, the trunk's not done", want: old, digest: dg("1")},
{name: "the trunk's build done", want: next, done: true, digest: dg("2")},
} {
b, fps := aNode(t, store, tools, lamp)
b.held = true // every grant held, so a contribution's credential stands in for nothing
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
mod := b.modules["node-tools"]
mod.FP2 = next
b.modules["node-tools"] = mod
b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}}
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
if row.done {
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}}
}
fps["node-tools"] = row.want
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
if err != nil {
t.Errorf("%s: the node is refused: %v", row.name, err)
continue
}
if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" {
t.Errorf("%s: lamp is not composed (held %v)", row.name, held)
}
if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) {
t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held)
}
if row.digest != "" {
key, _ := delivery.ComposedKey("n1", "node-tools", row.want)
if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) {
t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest)
}
}
}
}
func rawStrings(rs []json.RawMessage) []string {
var out []string
for _, r := range rs {
out = append(out, string(r))
}
return out
}
// Issue 774: a refusal of a held module's stand-in leads with why the module was held, its first reason, as mesh-self-check's
// on ace should have; a refusal naming no held module is as it was.
func aHeldModulesFirstReasonLeads(t *testing.T) {
held := map[string]string{"mesh-self-check": "mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check"}
for _, row := range []struct {
err, want string
}{
{"mesh-self-check runs as its own account and declares no own secret broker",
"mesh-self-check is held: mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check; " +
"its stand-in is then refused: mesh-self-check runs as its own account and declares no own secret broker"},
{"ace is the hub and has no endpoint", "ace is the hub and has no endpoint"},
} {
err := errors.New(row.err)
got := firstReason(err, held)
if got.Error() != row.want || !errors.Is(got, err) {
t.Errorf("refused %q, want %q", got, row.want)
}
}
}
// Issue 774, 2026-10-30 21:04: mesh-conditions and mesh-healers on novox and mesh-self-check on ace were placed before their
// bus accounts were issued. Held for "needs a secret called broker and none was made", each one's bare stub was then
// refused as "runs as its own account and declares no own secret broker", and that refusal failed every module of the
// node (79 on novox, 74 on ace). A held module's stand-in is never refused by a rule about its own runtime or its own
// secrets: the node composes every other module, and the held one keeps the words it was first held for.
func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
dg := "sha256:" + strings.Repeat("1", 64)
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
own := func(name string) string {
return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` +
`"own-secrets":{"broker":"/var/lib/` + name + `/broker"},"contributes":{"artifact-store":{"name":"` + name + `"}},` +
`"resources":[{"id":"account","type":"user","name":"` + name + `"}],` +
`"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`
}
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
for _, row := range []struct {
name string
issued []string // the own-account modules whose broker is issued
building bool // node-tools' build is not done, and nothing of it runs
held map[string]string // the modules held, each with words of the reason it was first held for
// ungranted gives the provider a grants file and no consumer's grant is issued yet (a newly placed module's
// usual state): a held module's contribution then stands in for its grant, and is left out (review round 1).
ungranted bool
}{
{name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}},
{name: "none issued, no grant issued", ungranted: true, held: map[string]string{"check": "broker", "heal": "broker"}},
{name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}},
{name: "both issued", issued: []string{"check", "heal"}},
// The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node.
{name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true,
held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}},
} {
provider := store
if row.ungranted {
provider = strings.Replace(store, `"receives":`, `"grants":{"artifact-store":"/var/lib/artifacts/grants"},"receives":`, 1)
}
b, fps := aNode(t, provider, tools, own("check"), lamp, own("heal"))
// Every grant held, so the held modules' contributions are composed whole; the row ungranted issues none.
b.held = !row.ungranted
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
for _, m := range row.issued {
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
}
for _, m := range []string{"node-tools", "check", "heal"} {
name := "tools"
if m == "node-tools" {
name = "runtime"
}
mod := b.modules[m]
mod.FP2 = fps[m]
b.modules[m] = mod
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}}
if m == "node-tools" && row.building {
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
}
}
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
if err != nil {
t.Errorf("%s: the node is refused: %v", row.name, err)
continue
}
// A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out
// of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left
// out of its stand-in, never its contributions, so the provider's received file names every consumer.
storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"])
// And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is
// not issued is composed without that contribution, never with "foreseen: made by the next send".
for key, c := range out {
if got, _ := json.Marshal(c.Resources); strings.Contains(string(got), "foreseen") {
t.Errorf("%s: %s applies a stand-in: %s", row.name, key, got)
}
}
for _, m := range []string{"check", "heal"} {
if row.ungranted {
break
}
if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) {
t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got)
}
}
for _, m := range []string{"node-tools", "check", "lamp", "heal"} {
key, _ := delivery.ComposedKey("n1", m, fps[m])
_, composed := out[key]
why, isHeld := held[m]
want, wantHeld := row.held[m]
if isHeld != wantHeld || composed == wantHeld {
t.Errorf("%s: %s held %v (%q), composed %v; want held %v", row.name, m, isHeld, why, composed, wantHeld)
} else if isHeld && !strings.Contains(why, want) {
t.Errorf("%s: %s is held for %q, want its first reason, %q", row.name, m, why, want)
}
}
}
}
+204
View File
@@ -0,0 +1,204 @@
package frombus
// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in
// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each
// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved
// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's
// runtime states and an HTTP fetch are compose/fromruntime's.
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
)
// The states a module names in its manifest's `reads` to compose through StatesReader.
const (
PlacementRead = delivery.PlacementRead
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
BuildsRead = delivery.BuilderModule + ".builds"
HaveRead = "node-engine.have"
ResolvedRead = "mesh-config.resolved"
)
// StatesReads are those reads, in a manifest's order.
var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
// States is a module's read states as its runtime gives them: a key's value, false when it has none.
type States interface {
Get(state, key string) (value []byte, found bool, err error)
Keys(state string) ([]string, error)
}
// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP).
type Fetch func(ctx context.Context, url string) ([]byte, error)
// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again
// takes in only a build it has not seen.
func StatesReader(s States, fetch Fetch) Reader {
return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}}
}
type statesReader struct {
s States
fetch Fetch
kept *keptTakeIns
}
func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) }
func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) }
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
keys, err := r.s.Keys(read)
if err != nil {
return fmt.Errorf("%s: %w", read, err)
}
for _, k := range keys {
v, found, err := r.s.Get(read, k)
if err != nil {
return fmt.Errorf("%s %s: %w", read, k, err)
}
if found {
visit(k, v)
}
}
return nil
}
func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) {
keys := delivery.NewPlacementKeys()
err := r.each(PlacementRead, func(k string, v []byte) {
_, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before
})
return keys.All(), err
}
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
out := map[string]delivery.NodeRecord{}
err := r.each(HaveRead, func(k string, v []byte) {
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
out[node] = *h.Record
}
}
})
return out, err
}
func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) {
out := map[string]delivery.Module{}
err := r.each(ModulesRead, func(k string, v []byte) {
if module, err := delivery.ParseModulesKey(k); err == nil {
if m, err := delivery.DecodeModule(v); err == nil {
out[module] = m
}
}
})
return out, err
}
func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) {
key, err := delivery.ManifestsKey(digest)
if err != nil {
return nil, err
}
v, found, err := r.s.Get(ManifestsRead, key)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("the indexer holds no manifest %s", digest)
}
m, err := delivery.DecodeManifest(v)
if err != nil {
return nil, err
}
return []byte(m.JSON), nil
}
func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) {
var out map[string]any
prefix := node + "." + module + "."
err := r.each(ResolvedRead, func(k string, v []byte) {
name, ok := strings.CutPrefix(k, prefix)
if !ok || strings.Contains(name, ".") {
return
}
if value, laid := catalogue.LaidValue(v); laid {
if out == nil {
out = map[string]any{}
}
out[name] = value
}
})
return out, err
}
func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
return nil, nil
}
func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
return nil, nil, nil
}
func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return nil, err
}
v, found, err := r.s.Get(BuildsRead, key)
if err != nil || !found {
return nil, err
}
b, err := delivery.DecodeBuilderBuild(v)
if err != nil {
return nil, err
}
return &b, nil
}
// archiveWithin bounds one read of a templates archive.
const archiveWithin = 30 * time.Second
func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) {
if r.fetch == nil {
return nil, fmt.Errorf("this holder reads no archive (%s)", url)
}
fetching, cancel := context.WithTimeout(ctx, archiveWithin)
defer cancel()
body, err := r.fetch(fetching, url)
if err != nil {
return nil, err
}
sum := sha256.Sum256(body)
if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") {
return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got)
}
return body, nil
}
func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) {
all, err := r.Values(ctx, node, module)
var out map[string]any
for _, k := range keys {
if v, ok := all[k]; ok {
if out == nil {
out = map[string]any{}
}
out[k] = v
}
}
return out, err
}
@@ -0,0 +1,236 @@
{
"module": "xterm",
"version": "1",
"moved": true,
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"claims": [
{
"name": "node-terminal-emulator",
"scope": "node",
"serves": [
"open"
]
}
],
"tools": [
"xterm_font",
"xterm_colours"
],
"config": {
"face": {
"type": "string",
"description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).",
"pattern": "[A-Za-z0-9 ._-]{1,64}",
"default": "JetBrainsMono Nerd Font"
},
"size": {
"type": "number",
"description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.",
"minimum": 6,
"maximum": 72,
"predefined": "font-size",
"default": 12
},
"save-lines": {
"type": "number",
"description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.",
"minimum": 0,
"maximum": 1000000,
"default": 10000
},
"term-name": {
"type": "string",
"description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.",
"pattern": "[A-Za-z0-9._-]{1,64}",
"default": "xterm-256color"
},
"render-font": {
"type": "boolean",
"description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.",
"default": true
},
"bold-mode": {
"type": "boolean",
"description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.",
"default": false
},
"always-bold-mode": {
"type": "boolean",
"description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.",
"default": false
},
"allow-bold-fonts": {
"type": "boolean",
"description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.",
"default": true
},
"background": {
"type": "string",
"description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#000000"
},
"foreground": {
"type": "string",
"description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#a8a8a8"
},
"color0": {
"type": "string",
"description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#000000"
},
"color1": {
"type": "string",
"description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#9e1828"
},
"color2": {
"type": "string",
"description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#008800"
},
"color3": {
"type": "string",
"description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#d2bb4b"
},
"color4": {
"type": "string",
"description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#414171"
},
"color5": {
"type": "string",
"description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#963c59"
},
"color6": {
"type": "string",
"description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#418179"
},
"color7": {
"type": "string",
"description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#bebebe"
},
"color8": {
"type": "string",
"description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#666666"
},
"color9": {
"type": "string",
"description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#bc5766"
},
"color10": {
"type": "string",
"description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#61a171"
},
"color11": {
"type": "string",
"description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#e7db52"
},
"color12": {
"type": "string",
"description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#5085af"
},
"color13": {
"type": "string",
"description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#a97a99"
},
"color14": {
"type": "string",
"description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#6ba4a4"
},
"color15": {
"type": "string",
"description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#ffffff"
},
"copy-binding": {
"type": "string",
"description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.",
"pattern": "[^:\\\\\\n]+",
"default": "Ctrl Shift ~Alt <Key> C"
},
"paste-binding": {
"type": "string",
"description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.",
"pattern": "[^:\\\\\\n]+",
"default": "Ctrl Shift ~Alt <Key> V"
}
},
"items": [
{
"collection": "environment",
"name": "terminal",
"attributes": {
"kind": "variable",
"name": "TERMINAL"
},
"template": "files/env-terminal"
},
{
"collection": "shell:xresources:normal",
"name": "xresources-normal",
"template": "files/xresources-normal"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "xterm"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/xterm-tools",
"binary": "xterm-tools",
"loads": [
"xterm-tools"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
@@ -0,0 +1,60 @@
{
"module": "mesh-provisioner-client",
"version": "1",
"claims": [
{
"name": "node-provisioner",
"scope": "node"
}
],
"own-secrets": {
"provisioner-broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-provisioner-client",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-provisioner-client",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-provisioner-client"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-provisioner-client",
"place": ".",
"unknown": "program"
},
{
"id": "client",
"type": "process",
"name": "mesh-provisioner-client",
"artifact": "client",
"run": [
"./mesh-provisioner-client"
],
"env": {
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
"MESH_PROVISIONER_STATE": "${dir:state}"
},
"user": "mesh-provisioner-client",
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "client",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-provisioner-client",
"binary": "mesh-provisioner-client"
}
]
}
}
@@ -0,0 +1,2 @@
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
${backup:lines}
@@ -0,0 +1,2 @@
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
${backup:data}
@@ -0,0 +1,6 @@
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
daily=${config:keep-daily}
weekly=${config:keep-weekly}
monthly=${config:keep-monthly}
hand=${config:keep-hand-taken-days}
@@ -0,0 +1,199 @@
{
"module": "restic",
"version": "1",
"moved": true,
"claims": [
{
"name": "node-backup",
"scope": "node",
"serves": [
"backed-up",
"now",
"restore"
]
}
],
"emits": [
"backed-up"
],
"tools": [
"backup_health",
"backup_delete_retired",
"backup_deleted"
],
"own-secrets": {
"repository": "${dir:state}/repository.secret"
},
"data": {
"own": [
{
"id": "repository",
"path": "${dir:repository}",
"class": "rebuildable",
"backup": "none",
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
},
{
"id": "state",
"path": "${dir:state}",
"class": "cache",
"why": "what the last nights and measurements were; the next night writes it again"
}
]
},
"config": {
"keep-daily": {
"type": "number",
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 14
},
"keep-weekly": {
"type": "number",
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 8
},
"keep-monthly": {
"type": "number",
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 6
},
"keep-hand-taken-days": {
"type": "number",
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 7
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": ".",
"unknown": "program"
},
{
"id": "repository",
"type": "directory",
"mode": "0700",
"unknown": "program"
},
{
"id": "declared",
"type": "file",
"path": "${dir:state}/backups.conf",
"mode": "0600",
"template": "files/backups.conf"
},
{
"id": "data-declared",
"type": "file",
"path": "${dir:state}/data.conf",
"mode": "0600",
"template": "files/data.conf"
},
{
"id": "retention",
"type": "file",
"path": "${dir:state}/retention.conf",
"mode": "0644",
"trusted": true,
"template": "files/retention.conf"
},
{
"id": "tool",
"type": "package",
"package": "restic"
},
{
"id": "sqlite",
"type": "package",
"package": "sqlite"
},
{
"id": "night",
"type": "process",
"name": "restic-night",
"artifact": "tools",
"run": [
"./restic-backups",
"night"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "tool",
"tool": "backup_health",
"interval": "60s",
"timeout": "20s",
"looks": 2,
"grace": "60s"
}
},
{
"id": "measure",
"type": "process",
"name": "restic-measure",
"artifact": "tools",
"run": [
"./restic-backups",
"measure"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/restic-backups",
"binary": "restic-backups",
"loads": [
"restic-backups"
],
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
@@ -0,0 +1,129 @@
{
"module": "de-spiegel",
"version": "1",
"slug": "spiegel",
"capabilities": [
"container-runtime"
],
"requires": [
"route",
"smtp"
],
"contributes": {
"route": {
"site": {
"label": "de-spiegel",
"port": 35621
},
"praktijk": {
"name": "${config:praktijk-name}",
"port": 35621
},
"praktijk-www": {
"name": "${config:praktijk-www-name}",
"redirect": "https://${config:praktijk-name}"
},
"psychologie": {
"name": "${config:psychologie-name}",
"port": 35621
},
"psychologie-www": {
"name": "${config:psychologie-www-name}",
"redirect": "https://${config:psychologie-name}"
}
},
"smtp": {
"account": "de-spiegel"
}
},
"binds": {
"route": "/var/lib/de-spiegel/route.json",
"smtp": "/var/lib/de-spiegel/smtp.json"
},
"config": {
"praktijk-name": {
"type": "string",
"description": "The public name the practice's site is served on; the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"praktijk-www-name": {
"type": "string",
"description": "The public name that redirects to the practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"psychologie-name": {
"type": "string",
"description": "The public name the psychology practice's site is served on; the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"psychologie-www-name": {
"type": "string",
"description": "The public name that redirects to the psychology practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
}
},
"listens": [
{
"port": 35621,
"protocol": "tcp",
"from": "mesh",
"why": "the de-spiegel site and its /contact endpoint over http; the practice's three public names are route grants, and the proxy reaches every one of them here"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"path": "/var/lib/de-spiegel",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/de-spiegel/server.env",
"mode": "0600",
"content": "SMTP_HOST=${bound:smtp:at}\nSMTP_PORT=${bound:smtp:port}\nSMTP_AUTH_USER=de-spiegel@${bound:smtp:domain}\nSMTP_AUTH_PASS=${secret:smtp}\n"
},
{
"id": "net",
"type": "network",
"name": "de-spiegel"
},
{
"id": "server",
"type": "container",
"name": "de-spiegel",
"artifact": "site",
"network": "de-spiegel",
"env-file": [
"/var/lib/de-spiegel/server.env"
],
"ports": [
"35621"
],
"secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (server/index.js); converting is this repository's change"
}
],
"build": {
"on": [
{
"arg": "NODE_BASE",
"image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9"
}
],
"artifacts": [
{
"name": "site",
"kind": "image",
"from": "Dockerfile"
}
]
},
"secrets": {
"smtp": "/var/lib/de-spiegel/smtp.secret"
}
}
@@ -0,0 +1,82 @@
{
"module": "f1-circuits",
"slug": "f1",
"version": "1",
"capabilities": [
"container-runtime"
],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "f1-circuits",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"listens": [
{
"name": "web",
"port": 8000,
"protocol": "tcp",
"from": "mesh",
"why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "cache",
"type": "directory",
"mode": "0700"
},
{
"id": "site-env",
"type": "file",
"path": "${dir:state}/site.env",
"mode": "0644",
"content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n"
},
{
"id": "server",
"type": "container",
"name": "f1-circuits",
"artifact": "cdn",
"ports": [
"8000"
],
"env-file": [
"${dir:state}/site.env"
],
"volumes": [
"${dir:cache}:/app/cache"
],
"restart-on": [
"site-env"
]
}
],
"build": {
"on": [
{
"arg": "PYTHON_BASE",
"image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df"
}
],
"artifacts": [
{
"name": "cdn",
"kind": "image",
"from": "service/Dockerfile"
}
]
}
}
+147
View File
@@ -0,0 +1,147 @@
package frombus
// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
type HeldPlans struct {
Plans map[string]catalogue.Plan
Held map[string]string
Err error
}
// Phase is one step of a composition and how long it took.
type Phase struct {
Name string `json:"phase"`
Took time.Duration `json:"took"`
}
// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one
// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err.
// took names each phase in order: the read, then each scope's builds and its plans.
func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) {
start := time.Now()
mark := func(name string) {
now := time.Now()
took = append(took, Phase{Name: name, Took: now.Sub(start)})
start = now
}
b, err := readBase(ctx, r, mesh, s)
mark("read")
builds := onceBuilds(r)
newestScope := s
newestScope.KeptAt = nil
for _, sc := range []struct {
scope Scope
into *HeldPlans
name string
}{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} {
held := map[string]string{}
sc.into.Held = held
refuse := func(module string, err error) { held[module] = err.Error() }
b.refuseOwn(refuse)
if err != nil {
sc.into.Err = err
continue
}
in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil)
mark("builds at " + sc.name)
if err != nil {
sc.into.Err = err
continue
}
sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
mark("plans at " + sc.name)
}
return at, newest, took
}
// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
// builds of the same moment.
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
type answer struct {
b *delivery.BuilderBuild
err error
}
read := map[string]answer{}
return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
key := module + " " + fp
if a, ok := read[key]; ok {
return a.b, a.err
}
b, err := r.Build(ctx, module, fp)
read[key] = answer{b, err}
return b, err
}
}
// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes,
// by the content it was made from, so a composition made again on every change takes in only a build it has not seen.
type takenInKeeper interface {
takenIn(key string) ([]byte, bool)
keepTakenIn(key string, raw []byte)
}
// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
raw, err := json.Marshal(b)
if err != nil {
return "", false
}
sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...))
return hex.EncodeToString(sum[:]), true
}
// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again.
const takenInAtMost = 512
// keptTakeIns is a reader's kept take-ins.
type keptTakeIns struct {
mu sync.Mutex
kept map[string][]byte
}
func (k *keptTakeIns) takenIn(key string) ([]byte, bool) {
k.mu.Lock()
defer k.mu.Unlock()
raw, ok := k.kept[key]
return raw, ok
}
func (k *keptTakeIns) keepTakenIn(key string, raw []byte) {
k.mu.Lock()
defer k.mu.Unlock()
if k.kept == nil || len(k.kept) >= takenInAtMost {
k.kept = map[string][]byte{}
}
k.kept[key] = raw
}
// PhasesSaid says the phases in one line, each in milliseconds.
func PhasesSaid(took []Phase) string {
out := ""
for i, p := range took {
if i > 0 {
out += ", "
}
out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds())
}
return out
}