diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 1d361ca..6bb593d 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "reflect" "regexp" + "slices" "strings" "testing" ) @@ -14,10 +15,108 @@ import ( // byte what the controller's composes. testdata/controller-user-lists.json was written by the controller's own // internal/broker (mesh-controller branch fix/1945-mesh-grants-publishes-memberships, and fix/1790-grants-create-buckets for the grants part creating declared streams), lists and memberships, from fixtures_test.go compiled there unchanged; composed.conf is the // controller's golden of the whole server configuration. A row that differs is the copy drifting from the controller, -// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13). +// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13), but for the grants the copy +// gives beyond it on purpose (beyondTheControllers): the copy grants every user everything the controller's grants, and +// only those more. // createdAdvisory is one subscription to a bucket's creation in a composed list, as stateCreatedHeard composes it. var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATED\.KV_[^"]*", `) +// beyondTheControllers is what the copy grants beyond the controller's on purpose (novox/hq ADR 0547 §2, ADR 0555 §3, +// issue 1126), by user: a node's account reads every node's own facts, `.node` of the have bucket where the node's +// record rides, and what it composes its own modules from (NodeComposeReads). After the take-over (ADR 0547 §8) the +// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes +// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation +// (issue 1790) is the other row beyond, matched by createdAdvisory. +func beyondTheControllers(user string) map[string]bool { + out := map[string]bool{} + node, ok := strings.CutPrefix(user, "node.") + if !ok { + return out + } + for _, s := range append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...) { + out[s] = true + } + return out +} + +// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not +// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5). +func mayBeyondTheControllers(user string) map[string]bool { + if _, ok := strings.CutPrefix(user, "node."); !ok { + return nil + } + return map[string]bool{VaultOwnCall(Principal{Kind: KindNode, Node: strings.TrimPrefix(user, "node.")}): true, + VaultSecretChanged: true} +} + +// quotedToken is a quoted token of a composed list. +var quotedToken = regexp.MustCompile(`"[^"]*"`) + +// grantsByUser splits a composed list into the text outside its users and each user's quoted tokens (its name, its +// password's hash, every subject it may publish or subscribe to), by user. +func grantsByUser(list string) (string, map[string]map[string]bool) { + var outside strings.Builder + users := map[string]map[string]bool{} + user := "" + for _, line := range strings.SplitAfter(list, "\n") { + trimmed := strings.TrimSpace(line) + if name, ok := strings.CutPrefix(trimmed, `{ user: "`); ok { + user, _, _ = strings.Cut(name, `"`) + users[user] = map[string]bool{} + } else if user != "" && trimmed == "]" { + user = "" + } + if user == "" { + outside.WriteString(line) + continue + } + for _, q := range quotedToken.FindAllString(line, -1) { + users[user][strings.Trim(q, `"`)] = true + } + } + return outside.String(), users +} + +// grantsAsTheControllers fails t unless the copy's list is the controller's, user for user, but for the grants it +// names on purpose (beyondTheControllers, the creation advisories): every grant of the controller's is the copy's, and +// the copy's beyond them are exactly those, every one of them there. +func grantsAsTheControllers(t *testing.T, got, want string) { + t.Helper() + gotOutside, gotUsers := grantsByUser(got) + wantOutside, wantUsers := grantsByUser(want) + if gotOutside != wantOutside { + t.Errorf("the list differs from the controller's outside its users:\n%s", gotOutside) + } + for user, was := range wantUsers { + now, ok := gotUsers[user] + if !ok { + t.Errorf("the copy has no user %s", user) + continue + } + intended := beyondTheControllers(user) + for g := range was { + if !now[g] { + t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g) + } + } + for g := range now { + if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) { + t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g) + } + } + for g := range intended { + if !now[g] { + t.Errorf("%s: the copy does not grant %s, which a node composing its own modules reads", user, g) + } + } + } + for user := range gotUsers { + if _, ok := wantUsers[user]; !ok { + t.Errorf("the copy has a user %s the controller's has not", user) + } + } +} + func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json")) if err != nil { @@ -56,10 +155,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { // the take-over of ADR 0547 milestone 2): each principal hears its own and read buckets created, so a state // missing at its start is bound when it appears. The controller's composer is frozen and its list no longer // loaded; while it is not, this row is left out of the comparison and nothing else. - list = createdAdvisory.ReplaceAllString(list, "") - if list != want[i].List { - t.Errorf("the list differs from the controller's:\n%s", list) - } + grantsAsTheControllers(t, list, want[i].List) // And every membership the records issue, as the controller's MembershipFor makes it (ADR 0547 §5): the // grants part publishes them. raw, err := json.Marshal(MembershipsFor(f)) @@ -109,6 +205,25 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS") } }) + // Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no + // other node's. + t.Run("a node asks the vault its own secrets", func(t *testing.T) { + one := Principal{Kind: KindNode, Node: "one", AsksItsOwnSecrets: true} + perms, err := PermissionsFor(one) + if err != nil { + t.Fatal(err) + } + if !MayPublish(perms, VaultOwnCall(one)) || MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})) { + t.Errorf("node one asks own as %v, and as two %v", MayPublish(perms, VaultOwnCall(one)), + MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"}))) + } + if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one"}); MayPublish(plain, VaultOwnCall(one)) { + t.Error("a node asks own while the vault does not serve it") + } + if !slices.Contains(perms.Subscribe, VaultSecretChanged) { + t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe) + } + }) t.Run("the controller's golden server configuration", func(t *testing.T) { seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} got, err := Compose( @@ -135,8 +250,6 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { if err != nil { t.Fatal(err) } - if got != string(want) { - t.Errorf("the configuration differs from the controller's golden:\n%s", got) - } + grantsAsTheControllers(t, got, string(want)) }) } diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 1ff25c8..b548eea 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -213,6 +213,10 @@ type Principal struct { // StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name // (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new. StatesItsKeyToTheVault bool + // AsksItsOwnSecrets is a machine principal whose node-engine may ask the vault's `own` in its own name (novox/hq + // ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes + // itself. Set while the vault's holder serves it. + AsksItsOwnSecrets bool // FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants // part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows). FollowsGrantsUserList bool @@ -344,6 +348,10 @@ var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"} // novox/hq ADR 0372); granted as Principal.StatesItsKeyToTheVault says. var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"} +// VaultOwn is the vault's verb a machine's node-engine asks in its own name for its modules' own secrets sealed to its +// key (issue 1126, ADR 0550 §5); granted as Principal.AsksItsOwnSecrets says. +var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"} + // VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant // chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the // controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release @@ -882,6 +890,14 @@ func PermissionsFor(p Principal) (Permissions, error) { return Permissions{}, err } pub = append(pub, haveKeys...) + // And every node's own facts, `.node`, its record among them (issue 1126, ADR 0555 §3): a node agent composing + // its own modules reads the other nodes' public records, which its firewall and resolver need (ADR 0547 §2). Read + // only: each node still writes its own keys alone. + pub = append(pub, readGrants(delivery.HaveBucket, "*."+delivery.HaveNodeWord)...) + // And what it composes its own modules from (ADR 0547 §2, ADR 0549 §3): the indexer's placement, modules and + // manifests, the builder's builds (the manifest of a build its want names), and mesh-config's resolved values of + // its own node alone. Read only. + pub = append(pub, NodeComposeReads(p.Node)...) // And an object of the declaration bucket by its name, and nothing of the bucket's list or writing // (novox/hq ADR 0486 §2, issue 1697). fileReads, err := DeclarationFilesReads(p.Node) @@ -915,6 +931,14 @@ func PermissionsFor(p Principal) (Permissions, error) { if p.StatesItsKeyToTheVault { pub = append(pub, "mesh.seat."+VaultNodeKey.Seat+"."+CallKind+"."+VaultNodeKey.Verb+"."+CallerToken(p.Username())) } + // And, while the vault serves it, its modules' own secrets in its own name (issue 1126): sealed to its key, for the + // compositions it makes itself. + if p.AsksItsOwnSecrets { + pub = append(pub, VaultOwnCall(p)) + // And the vault's word that a secret changed (names and generations, never a value), so the node composes again + // on it rather than at the next unrelated change (ADR 0547 §2). + sub = append(sub, VaultSecretChanged) + } // And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR // 0547 clause 7): one key, and an object by its name. if p.FollowsGrantsUserList { @@ -1784,3 +1808,12 @@ const ( nodeToolsService = "node-tools" leaseKey = "holder" ) + +// VaultOwnCall is the subject a machine principal's node-engine asks the vault's `own` on, in its own name. +func VaultOwnCall(p Principal) string { + return "mesh.seat." + VaultOwn.Seat + "." + CallKind + "." + VaultOwn.Verb + "." + CallerToken(p.Username()) +} + +// VaultSecretChanged is the vault's event that a secret changed (a name and its generation, never a value), which a +// node composing its own hears to compose again (issue 1126, ADR 0547 §2). +var VaultSecretChanged = "mesh.mod." + VaultOwn.Seat + ".event.secret-changed" diff --git a/go/buscompose/state.go b/go/buscompose/state.go index f772cae..a0d6feb 100644 --- a/go/buscompose/state.go +++ b/go/buscompose/state.go @@ -3,6 +3,7 @@ package buscompose import ( "fmt" "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/delivery" "sort" "strings" "time" @@ -306,3 +307,18 @@ func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err sort.Strings(undeclared) return undeclared, nil } + +// ResolvedBucket is mesh-config's state of resolved values, keyed `..`. +const ResolvedBucket = "mesh-config_resolved" + +// NodeComposeReads is what a node's agent reads to compose its own modules (novox/hq ADR 0547 §2, ADR 0549 §3, issue +// 1126): every key of the indexer's placement, modules and manifests and of the builder's builds, and of mesh-config's +// resolved values only its own node's, `.>`. Reads only; the node is checked as every subject token is by the +// caller's principal. +func NodeComposeReads(node string) []string { + var out []string + for _, bucket := range []string{delivery.PlacementBucket, delivery.ModulesBucket, delivery.ManifestsBucket, delivery.BuilderBuildsBucket} { + out = append(out, readGrants(bucket, "")...) + } + return append(out, readGrants(ResolvedBucket, node+".>")...) +} diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 472a0c4..56dbcc9 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -159,6 +159,7 @@ func Users(r Records) ([]Principal, error) { } out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks, MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), + AsksItsOwnSecrets: servesVerb(r, VaultOwn), FollowsGrantsUserList: bus}) // **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one // per machine that runs it, delivered to the module running it. diff --git a/go/catalogue/verbs.go b/go/catalogue/verbs.go index 11f0b3e..9f980fb 100644 --- a/go/catalogue/verbs.go +++ b/go/catalogue/verbs.go @@ -126,6 +126,13 @@ var ControllerVerbs = []Verb{ {Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.", Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"}), Replaces: []string{"hostnamectl", "uptime"}}, + {Name: "placement-export", Description: "Each machine's placement as the catalogue's placement file says it (novox/hq ADR 0555 " + + "§6, issue 1126): what a person decided of the machine (endpoint, site, hub, address on the private network, public " + + "domain, accounts, adoption, the tunnel found at adoption) and where its modules run (in order, with host ports, " + + "bindings, config placement layers, pins and holdings), read by the controller's own code from its own store, as JSON. " + + "Read-only; the one verb the controller gains for its exit (the operator, 2026-10-26 19:06:40), read once by the seed " + + "of the placement files.", + Input: schema(map[string]string{"node": "one machine's name; every machine when absent"}, nil)}, {Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " + "and which machines run it.", Input: schema(nil, nil)}, diff --git a/go/cmd/placement-seed/main.go b/go/cmd/placement-seed/main.go new file mode 100644 index 0000000..93fae61 --- /dev/null +++ b/go/cmd/placement-seed/main.go @@ -0,0 +1,139 @@ +// placement-seed writes the catalogue's first placement files (novox/hq ADR 0549 §4, ADR 0555 §6, issue 1126) from the +// answer of the controller's one read-only verb `placement-export`: one `placement/.json` per node, each the +// node's placement as the controller's own code read it from its own store, checked as the catalogue's check checks it +// (delivery.CheckPlacements) before anything is written. The pull request it makes merges only when its composition check +// composes every node equal to what it runs. +// +// placement-seed -catalogue < export.json +// placement-seed -catalogue -export export.json +// +// export.json is the verb's answer as it prints it ({"": , ...}, or the verb's wrapping with it under +// "answer"). It opens no network connection, bus or store; it writes only /placement/.json, and refuses to +// write over a placement file already there. Exit codes: 0 written; 1 the export cannot be read, is refused by the +// check, or a file is there; 2 the flags are wrong. +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +func main() { + catalogue := flag.String("catalogue", "", "the checkout of the catalogue to write placement/.json into") + export := flag.String("export", "", "the verb's answer; standard input when absent") + flag.Parse() + if *catalogue == "" || flag.NArg() > 0 { + fmt.Fprintln(os.Stderr, "usage: placement-seed -catalogue [-export ]") + os.Exit(2) + } + in := io.Reader(os.Stdin) + if *export != "" { + f, err := os.Open(*export) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer f.Close() + in = f + } + written, err := Seed(in, *catalogue) + for _, path := range written { + fmt.Println(path) + } + if err != nil { + fmt.Fprintln(os.Stderr, "placement-seed:", err) + os.Exit(1) + } +} + +// Seed reads the export and writes each node's placement file under root, answering the paths written. Nothing is +// written when the export does not read, the check refuses it, or a placement file is already there. +func Seed(export io.Reader, root string) ([]string, error) { + raw, err := io.ReadAll(export) + if err != nil { + return nil, err + } + placements, err := read(raw) + if err != nil { + return nil, err + } + all := func(string) bool { return true } + if refused := delivery.CheckPlacements(placements, all, all); len(refused) > 0 { + return nil, fmt.Errorf("the export is refused by the catalogue's check: %v", refused) + } + nodes := make([]string, 0, len(placements)) + for node := range placements { + nodes = append(nodes, node) + } + sort.Strings(nodes) + files := map[string][]byte{} + for _, node := range nodes { + p := placements[node] + p.Revision = "" // only the indexer writes a revision, into the key + if _, err := delivery.EncodePlacement(p); err != nil { + return nil, fmt.Errorf("%s: %w", node, err) + } + p.V = delivery.Version + body, err := json.MarshalIndent(p, "", " ") + if err != nil { + return nil, err + } + path := filepath.Join(root, filepath.FromSlash(delivery.PlacementFile(node))) + if _, err := os.Stat(path); err == nil { + return nil, fmt.Errorf("%s is already there; the seed is written once (ADR 0549 §4)", path) + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + files[path] = append(body, '\n') + } + var written []string + for _, node := range nodes { + path := filepath.Join(root, filepath.FromSlash(delivery.PlacementFile(node))) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return written, err + } + if err := os.WriteFile(path, files[path], 0o644); err != nil { + return written, err + } + written = append(written, path) + } + return written, nil +} + +// read is the export as the verb prints it, or wrapped as a verb's answer (the controller's verbAnswer: its JSON under +// "answer"). +func read(raw []byte) (map[string]delivery.Placement, error) { + var wrapped struct { + Answer json.RawMessage `json:"answer"` + } + if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Answer) > 0 { + raw = wrapped.Answer + } + var byNode map[string]json.RawMessage + if err := json.Unmarshal(raw, &byNode); err != nil { + return nil, fmt.Errorf("the export is not {: }: %w", err) + } + if len(byNode) == 0 { + return nil, errors.New("the export names no node") + } + out := map[string]delivery.Placement{} + for node, body := range byNode { + p, err := delivery.DecodePlacement(body) + if err != nil { + return nil, fmt.Errorf("%s: %w", node, err) + } + if p.Node != node { + return nil, fmt.Errorf("the export's placement of %s names the node %q", node, p.Node) + } + out[node] = p + } + return out, nil +} diff --git a/go/cmd/placement-seed/main_test.go b/go/cmd/placement-seed/main_test.go new file mode 100644 index 0000000..156b4f4 --- /dev/null +++ b/go/cmd/placement-seed/main_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/compose/frombus" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Issue 1126 (ADR 0549 §4, ADR 0555 §6): the seed writes one placement file per node of the export, as the catalogue's +// check and the indexer read it, from the verb's answer bare or wrapped; it writes nothing for an export the check +// refuses, and never over a file already there. +func TestTheSeedWritesEachNodesPlacementFileOnce(t *testing.T) { + export := `{"answer":{"ace":{"v":1,"node":"ace","revision":"x","decided":{"address":"10.77.0.2"},"modules":[{"module":"plex","ports":{"32400":32400}}]},` + + `"novox":{"v":1,"node":"novox","decided":{"hub":true,"address":"10.77.0.1"},"modules":[]}}}` + root := t.TempDir() + written, err := Seed(strings.NewReader(export), root) + if err != nil || len(written) != 2 { + t.Fatalf("written %v, %v", written, err) + } + raw, err := os.ReadFile(filepath.Join(root, "placement", "ace.json")) + if err != nil { + t.Fatal(err) + } + p, err := delivery.DecodePlacement(raw) + if err != nil || p.Node != "ace" || p.Revision != "" || p.HostPorts()["plex"][32400] != 32400 { + t.Errorf("ace's file reads %+v, %v", p, err) + } + if _, err := Seed(strings.NewReader(export), root); err == nil || !strings.Contains(err.Error(), "already there") { + t.Errorf("a second seed over the files: %v", err) + } + clash := `{"ace":{"v":1,"node":"ace","decided":{"address":"10.77.0.1"},"modules":[]},"novox":{"v":1,"node":"novox","decided":{"address":"10.77.0.1"},"modules":[]}}` + other := t.TempDir() + if _, err := Seed(strings.NewReader(clash), other); err == nil || !strings.Contains(err.Error(), "both have the address") { + t.Errorf("an export the check refuses: %v", err) + } + if entries, _ := os.ReadDir(filepath.Join(other, "placement")); len(entries) != 0 { + t.Errorf("a refused export wrote %v", entries) + } + // What the controller's placement-export prints, frombus.Export per node, is what the seed takes: a node with no + // module placed included. + in := compose.Input{Nodes: []compose.Node{{Name: "solo", Place: compose.Place{Hub: true, Address: "10.77.0.9"}}}} + exported, err := frombus.Export(in, "solo") + if err != nil { + t.Fatal(err) + } + answer, _ := json.Marshal(map[string]any{"answer": map[string]delivery.Placement{"solo": exported}}) + if written, err := Seed(strings.NewReader(string(answer)), t.TempDir()); err != nil || len(written) != 1 { + t.Errorf("Export's answer %s was not seeded: %v, %v", answer, written, err) + } + for _, bad := range []string{`[]`, `{}`, `{"ace":{"v":1,"node":"novox","modules":[]}}`} { + if _, err := Seed(strings.NewReader(bad), t.TempDir()); err == nil { + t.Errorf("%s was seeded", bad) + } + } +} diff --git a/go/compose/composed.go b/go/compose/composed.go index 9fe66a3..651357d 100644 --- a/go/compose/composed.go +++ b/go/compose/composed.go @@ -2,8 +2,10 @@ package compose import ( "encoding/json" + "errors" "fmt" "sort" + "strings" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -16,6 +18,26 @@ import ( // (mesh-hand-compose) gets that one. Each module's Data says whether its manifest declares data. The compositions are not // signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest). func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) { + return compositions(in, node, fps, mode, false) +} + +// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's +// sealed copy, a vault box, the certificate, the bus's user list) or a port it does not give: what a send would make, +// never what may be applied. +var ErrStoodIn = errors.New("compose: the composition stands in for what its input does not carry") + +// BusUsersStandIn is what a composition says it stood in for when its input carries no bus user list. +const BusUsersStandIn = "the bus's user list" + +// CompositionsApplied is Compositions for a node that applies what it composed itself (novox/hq ADR 0547 §2, issue +// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with +// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list +// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself. +func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) { + return compositions(in, node, fps, Reading, true) +} + +func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped []string, err error) { reading := in reading.Nodes = append([]Node(nil), in.Nodes...) found := false @@ -35,6 +57,24 @@ func Compositions(in Input, node string, fps map[string]string, mode Mode) (out if len(problems) > 0 { return nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems) } + if applied { + var stood []string + for _, x := range d.StoodIn { + // The bus's user list is the bus node's own to follow, written alone and never from a composition the node + // made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied. + if x != BusUsersStandIn { + stood = append(stood, x) + } + } + stood = append(stood, d.Foreseen...) + for _, p := range d.ForeseenPorts { + stood = append(stood, "port "+p) + } + if len(stood) > 0 { + sort.Strings(stood) + return nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", ")) + } + } body, err := d.Body() if err != nil { return nil, nil, err diff --git a/go/compose/credentials_needed.go b/go/compose/credentials_needed.go new file mode 100644 index 0000000..a3a9bc1 --- /dev/null +++ b/go/compose/credentials_needed.go @@ -0,0 +1,64 @@ +package compose + +import ( + "fmt" + "sort" +) + +// CredentialKey names one pair credential by the ends it joins: the provision, the consumer's node and module, the +// provider's node, and the local name where a module keeps several (Credential's and Issued's key). +type CredentialKey struct { + Provision string `json:"provision"` + Consumer string `json:"consumer"` + ConsumerModule string `json:"consumer_module"` + Provider string `json:"provider"` + Local string `json:"local,omitempty"` +} + +// CredentialsNeeded is every pair credential node's composition reads (novox/hq ADR 0547 §2, ADR 0550 §5, issue 1126): +// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's, +// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the +// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the +// node's own apply refuses (CompositionsApplied). +func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) { + c := newComposer(in) + resolved, err := c.resolution(node) + if err != nil { + return nil, nil, err + } + for _, n := range resolved.Needs { + if n.ByRecord || n.From == "" { + continue + } + asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For, + Provider: n.From, Local: n.Local}) + } + rows, err := c.rowsFor(node) + if err != nil { + return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err) + } + for _, r := range rows { + asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer, + ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local}) + } + sortKeys(asConsumer) + sortKeys(asProvider) + return asConsumer, asProvider, nil +} + +func sortKeys(ks []CredentialKey) { + sort.Slice(ks, func(i, j int) bool { + a, b := ks[i], ks[j] + switch { + case a.Provision != b.Provision: + return a.Provision < b.Provision + case a.Provider != b.Provider: + return a.Provider < b.Provider + case a.Consumer != b.Consumer: + return a.Consumer < b.Consumer + case a.ConsumerModule != b.ConsumerModule: + return a.ConsumerModule < b.ConsumerModule + } + return a.Local < b.Local + }) +} diff --git a/go/compose/credentials_test.go b/go/compose/credentials_test.go index ba4db0c..0697358 100644 --- a/go/compose/credentials_test.go +++ b/go/compose/credentials_test.go @@ -2,6 +2,7 @@ package compose import ( "encoding/json" + "errors" "strings" "testing" @@ -78,6 +79,16 @@ func TestAnOwnSecretHeldIsCarriedAndOneNotHeldIsForeseenOrLeftOut(t *testing.T) t.Errorf("a held secret with no value is not named: %v", got.Rendering.Foreseen) } unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) }) + + // 1126 (ADR 0547 §2): a node applying what it composed itself never applies a stand-in: a held secret with no value in the + // Input is refused, naming it; held with its value, it composes. + fps := map[string]string{"assistant": "src2:" + strings.Repeat("a", 64), overlay.Name: "src2:" + strings.Repeat("b", 64)} + if _, _, err := CompositionsApplied(in, "laptop", fps); !errors.Is(err, ErrStoodIn) || !strings.Contains(err.Error(), "assistant/password") { + t.Errorf("a stand-in was not refused for a node's own apply: %v", err) + } + if out, _, err := CompositionsApplied(mk(), "laptop", fps); err != nil || len(out) == 0 { + t.Errorf("a held secret with its value is refused for a node's own apply: %v", err) + } } // An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228): nothing is composed or @@ -161,6 +172,15 @@ func aGrantMesh(t *testing.T) Input { // the sealed copy a stand-in is composed and named (novox/hq plan.go grantsFor). func TestAGrantIsCreatedForEachCredentialMadeForAConsumer(t *testing.T) { in := aGrantMesh(t) + // 1126 (ADR 0550 §5): the credentials each node's composition reads, by their ends: laptop's files consumes anchor's + // s3-bucket, and anchor grants it; the node composing its own asks the vault for exactly these. + pair := CredentialKey{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor"} + if consumer, provider, err := CredentialsNeeded(in, "laptop"); err != nil || len(provider) != 0 || len(consumer) != 1 || consumer[0] != pair { + t.Errorf("laptop needs %v as consumer and %v as provider (%v), want %v as consumer", consumer, provider, err, pair) + } + if consumer, provider, err := CredentialsNeeded(in, "anchor"); err != nil || len(consumer) != 0 || len(provider) != 1 || provider[0] != pair { + t.Errorf("anchor needs %v as consumer and %v as provider (%v), want %v as provider", consumer, provider, err, pair) + } got := rendered(t, in, "anchor", Foreseeing) if len(got.Rendering.Grants) != 1 { t.Fatalf("grants are %+v", got.Rendering.Grants) diff --git a/go/compose/frombus/export.go b/go/compose/frombus/export.go new file mode 100644 index 0000000..ee55f8b --- /dev/null +++ b/go/compose/frombus/export.go @@ -0,0 +1,111 @@ +package frombus + +import ( + "fmt" + "sort" + "strconv" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Export is one node's placement file as the composition input the controller fills says it (novox/hq ADR 0555 §6, +// issue 1126): 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 seed writes into the catalogue. +// 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 the placement file is read at, with a list of modules even when none is placed: the export is the + // file the seed writes (cmd/placement-seed), 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 +} diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go new file mode 100644 index 0000000..4601762 --- /dev/null +++ b/go/compose/frombus/frombus.go @@ -0,0 +1,358 @@ +// Package frombus fills mesh-sdk's composition input from what is on the bus, so a node agent, the composition check +// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126). +// +// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its +// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the assigner's composition check for every +// node (ADR 0539 §6's check, ADR 0547 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential +// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads. +// +// **What it reads, and from whom.** +// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the +// indexer's state `placement`, one key per node (ADR 0549 §2); +// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and +// accounts): the same placement key, Placement.Decided; +// - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the +// node's own have key, `.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided, +// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both; +// - every module's manifest: the indexer's `modules.` and its manifests by digest, or the digest the +// composing node's want names where it runs another build (Scope.Kept); +// - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the +// caller composes every node (ADR 0547 §2, the prepare step's L4). +// +// **Own secrets and pair credentials** come from the vault sealed to the composing node's key (OwnSecrets, Credentials), +// for that node alone: as a consumer and as a provider, exactly the keys compose.CredentialsNeeded names. **Not yet +// read** (each a later subtask of issue 1126): licences (none is placed today) and core boxes. Without one +// the composition stands in for it, and Compose refuses a composition that stands in for anything (compose.ErrStoodIn): +// a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is +// always empty. +package frombus + +import ( + "context" + "encoding/json" + "fmt" + "net/netip" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/overlay" +) + +// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a +// read that failed, never an absent key (an absent key is an empty answer). +type Reader interface { + // Placements is every node's placement, by node (delivery.PlacementBucket). + Placements(ctx context.Context) (map[string]delivery.Placement, error) + // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of + // delivery.HaveBucket (ADR 0555 §3). + Records(ctx context.Context) (map[string]delivery.NodeRecord, error) + // Modules is every module the indexer holds, by module (delivery.ModulesBucket). + Modules(ctx context.Context) (map[string]delivery.Module, error) + // Manifest is the raw manifest of a digest (delivery.ManifestsBucket). + Manifest(ctx context.Context, digest string) ([]byte, error) + // Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`, + // keys `..`), decoded; nil when it has none. + Values(ctx context.Context, node, module string) (map[string]any, error) + // OwnSecrets is the own secrets of the composing node's modules, each sealed to that node's key, as the vault answers + // the node's engine (the vault's verb `own`, ADR 0550 §5); asked only for the node composing itself. + OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecretRow, error) + // Credentials is, for the composing node, each pair credential it reads as a consumer, held and sealed to its key, + // and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key + // the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it. + Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) +} + +// Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the +// internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take +// compose's defaults. +type Mesh struct { + OverlayCIDR string + InternalSuffix string + Authority string + // Predefined is the list of predefined keys mesh-config published, which a manifest's values falling back to a + // predefined key are judged against; nil reads each manifest as the indexer does (catalogue.ParseManifestForInputs). + Predefined catalogue.PredefinedList +} + +// Scope says who composes. Node is the node composing; only its config values are read unless Every is set, as the +// composition check sets it to compose every node. Kept is, by module, the manifest digest the composing node's wants +// name where it differs from the indexer's trunk: that node composes the module as it runs it. +type Scope struct { + Node string + Every bool + Kept map[string]string +} + +// Input is the composition input for s, read through r. +func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) { + placements, err := r.Placements(ctx) + if err != nil { + return compose.Input{}, fmt.Errorf("frombus: placement: %w", err) + } + records, err := r.Records(ctx) + if err != nil { + return compose.Input{}, fmt.Errorf("frombus: node records: %w", err) + } + modules, err := r.Modules(ctx) + if err != nil { + return compose.Input{}, fmt.Errorf("frombus: the indexer's modules: %w", err) + } + if _, ok := placements[s.Node]; !ok && !s.Every { + return compose.Input{}, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node) + } + + in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority, + Catalogue: map[string]catalogue.Manifest{}} + if in.Authority == "" { + // The mesh's own certificate, public, as the composing node holds it; for the check of every node, as the first + // node holding one does. + if rec, ok := records[s.Node]; ok && rec.Authority != "" { + in.Authority = rec.Authority + } else { + for _, node := range sortedKeys(records) { + if a := records[node].Authority; a != "" { + in.Authority = a + break + } + } + } + } + read := func(module, digest string) (catalogue.Manifest, error) { + raw, err := r.Manifest(ctx, digest) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err) + } + if mesh.Predefined != nil { + return catalogue.ParseManifestWith(raw, mesh.Predefined) + } + return catalogue.ParseManifestForInputs(raw) + } + for _, name := range sortedKeys(modules) { + m := modules[name] + if m.Manifest == "" { + continue // a registered module the indexer has not read: nothing to compose it from + } + man, err := read(name, m.Manifest) + if err != nil { + return compose.Input{}, err + } + in.Catalogue[name] = man + } + + // The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes + // with mesh-sdk and always wins, as the controller's providedModules and the grants part have it. + provided, err := json.Marshal(overlay.Manifest()) + if err != nil { + return compose.Input{}, err + } + var private catalogue.Manifest + if err := json.Unmarshal(provided, &private); err != nil { + return compose.Input{}, fmt.Errorf("frombus: the private network's manifest mesh-sdk provides: %w", err) + } + in.Catalogue[overlay.Name] = private + + var missing []string + for _, node := range nodesOf(placements, records) { + rec, hasRecord := records[node] + p := placements[node] + if !hasRecord { + if len(p.Modules) > 0 { + missing = append(missing, node) + } + continue + } + n := nodeOf(rec, p) + withValues := s.Every || node == s.Node + for _, m := range p.Modules { + var values map[string]any + if withValues { + if values, err = r.Values(ctx, node, m.Module); err != nil { + return compose.Input{}, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err) + } + } + if layers := m.Layers(node, values); layers != nil { + if n.Settings == nil { + n.Settings = map[string][]catalogue.Layer{} + } + n.Settings[m.Module] = layers + } + } + if node == s.Node { + for _, module := range sortedKeys(s.Kept) { + man, err := read(module, s.Kept[module]) + if err != nil { + return compose.Input{}, err + } + if n.Kept == nil { + n.Kept = map[string]catalogue.Manifest{} + } + n.Kept[module] = man + } + } + in.Nodes = append(in.Nodes, n) + in.Holdings = append(in.Holdings, p.Held()...) + // A tunnel found at adoption is the node's private network's when the node's tunnel key is the tunnel's (the + // controller's inv.Tunnels); the hub's found tunnel is the mesh's adopted one whether or not the hub took it over + // (inv.AdoptedTunnel), and its peers are carried once it did (inv.CarriedPeers). + if t := p.Decided.Tunnel; t != nil { + if rec.TunnelKey == t.PublicKey { + if in.Tunnels == nil { + in.Tunnels = map[string]compose.FoundTunnel{} + } + in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*t), NodeAdopted: p.Decided.Adopted} + } + if p.Decided.Hub { + in.Adopted = &compose.AdoptedTunnel{Tunnel: tunnelOf(*t), Hub: node, Adopted: rec.TunnelKey == t.PublicKey} + } + } + } + if in.Adopted != nil && in.Adopted.Adopted { + in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records) + } + if len(missing) > 0 { + return compose.Input{}, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet", + strings.Join(missing, ", ")) + } + if !s.Every { + if _, ok := records[s.Node]; !ok { + return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node) + } + // Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5). + // Asked only when a module placed on it declares one: a node none of whose modules has a secret composes + // whether or not the vault answers. + if declaresOwnSecrets(in, s.Node) { + if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil { + return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err) + } + } + // The core's credentials travel as the vault's box sealed to the machine (ADR 0349 §16): the same values, which + // the composition reads for a core module as its kept boxes. + for _, o := range in.OwnSecrets { + if json.Valid([]byte(o.Sealed)) { + in.KeptCoreBoxes = append(in.KeptCoreBoxes, compose.CoreBox{Node: o.Node, Module: o.Module, Name: o.Name, + SealedTo: o.SealedTo, Box: json.RawMessage(o.Sealed)}) + } + } + } + return in, nil +} + +// Compose is the node's compositions over the bus (ADR 0547 §2): Input for s, then compose.CompositionsApplied at the +// fingerprints fps gives each module: composed in the Reading mode as the controller composes them, and refused when it +// stands in for anything the input does not carry (compose.ErrStoodIn), since the node applies what it composed. +func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (map[string]delivery.Composed, []string, error) { + in, err := Input(ctx, r, mesh, s) + if err != nil { + return nil, nil, err + } + // The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 §5). + asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) + if err != nil { + return nil, nil, err + } + if len(asConsumer)+len(asProvider) > 0 { + if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { + return nil, nil, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err) + } + } + return compose.CompositionsApplied(in, s.Node, fps) +} + +// declaresOwnSecrets says a module assigned to node declares an own secret, in the manifest the node composes it from. +func declaresOwnSecrets(in compose.Input, node string) bool { + for _, n := range in.Nodes { + if n.Name != node { + continue + } + for _, module := range n.Assigned { + m, ok := n.Kept[module] + if !ok { + m = in.Catalogue[module] + } + if len(m.OwnSecrets) > 0 { + return true + } + } + } + return false +} + +// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person +// decided of it, and where its modules run). +func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node { + d := p.Decided + n := compose.Node{ + Name: rec.Node, + Place: compose.Place{Key: rec.TunnelKey, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address}, + Assigned: p.Assigned(), PublicDomain: d.PublicDomain, + Account: d.Account, AccountHome: d.AccountHome, AgentAccount: d.AgentAccount, AgentAccountHome: d.AgentHome, + Adopted: d.Adopted, Taken: d.Taken, Pins: p.ChosenPins(), Bound: p.Bound(), Ports: p.HostPorts(), SealingKey: rec.SealingKey, + OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid, + ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0, + } + if len(rec.Capabilities) > 0 { + n.Capabilities = make(map[string]bool, len(rec.Capabilities)) + for _, c := range rec.Capabilities { + n.Capabilities[c] = true + } + } + return n +} + +func tunnelOf(t delivery.RecordTunnel) compose.Tunnel { + out := compose.Tunnel{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 _, p := range t.Peers { + out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address}) + } + return out +} + +// carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key +// where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers). +func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer { + byKey := map[string]string{} + for node, rec := range records { + if rec.TunnelKey != "" { + byKey[rec.TunnelKey] = node + } + } + var out []compose.CarriedPeer + for _, p := range t.Peers { + address, _, _ := strings.Cut(p.Address, "/") + out = append(out, compose.CarriedPeer{PublicKey: p.PublicKey, Address: address, EnrolledAs: byKey[p.PublicKey], Named: names[p.PublicKey]}) + } + sort.SliceStable(out, func(i, j int) bool { + a, errA := netip.ParseAddr(out[i].Address) + b, errB := netip.ParseAddr(out[j].Address) + if errA != nil || errB != nil { + return out[i].Address < out[j].Address + } + return a.Less(b) + }) + return out +} + +// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in. +func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string { + seen := map[string]bool{} + for n := range placements { + seen[n] = true + } + for n := range records { + seen[n] = true + } + return sortedKeys(seen) +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go new file mode 100644 index 0000000..1d7bbcc --- /dev/null +++ b/go/compose/frombus/frombus_test.go @@ -0,0 +1,241 @@ +package frombus + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "os" + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/overlay" +) + +// bus is a Reader over values held in memory, counting whose config values were read. +type bus struct { + 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 +} + +func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { + return b.placements, nil +} +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, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { + return nil, nil, 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) { + ctx := context.Background() + xterm, err := os.ReadFile("../../catalogue/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.ParseManifestForInputs(xterm) + if err != nil { + t.Fatal(err) + } + parsedMedia, err := catalogue.ParseManifestForInputs(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) + } + 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, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps) + 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)) + } + + // 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") + } + + kept := mk() + kept.manifests["old"] = xterm + in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", Kept: map[string]string{"xterm": "old"}}) + if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": parsed}) || in.Nodes[0].Kept != nil { + t.Errorf("the want's manifest is kept on the composing node alone: %v, %v", in.Nodes, err) + } + + 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 unread": {func(b *bus) {}, Scope{Node: "laptop", Kept: map[string]string{"xterm": "gone"}}, "digest gone"}, + } { + 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) + } + } +} diff --git a/go/compose/render.go b/go/compose/render.go index a50a65e..e8dca30 100644 --- a/go/compose/render.go +++ b/go/compose/render.go @@ -113,7 +113,7 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) { r.BusUsers = c.in.BusUsers if r.BusUsers == "" { r.BusUsers = ForeseenBusUsers - foreseen("the bus's user list") + foreseen(BusUsersStandIn) } if len(c.in.BusMissing) > 0 { out.Said = append(out.Said, fmt.Sprintf("the bus's user list leaves out %d user(s) the mesh has minted no credential "+ diff --git a/go/delivery/assigner.go b/go/delivery/assigner.go index 4aff2fd..7987797 100644 --- a/go/delivery/assigner.go +++ b/go/delivery/assigner.go @@ -166,8 +166,9 @@ func checkFingerprint(fp, of string) error { return nil } -// Want is a node's want for a module: the fingerprint FP the node should run and the digest Composed of the one -// composition of it the node may apply (ADR 0539 clause 1), or Removed for a module that left the node's modules (then +// Want is a node's want for a module: the fingerprint FP the node should run and, before ADR 0547's second milestone, +// the digest Composed of the one composition of it the node may apply (ADR 0539 clause 1; from that milestone the +// node composes its own, ADR 0547 §2, and a want names none), or Removed for a module that left the node's modules (then // both are empty). When the module's newest fingerprint is not yet allowed on this node, Newest names it and Rule the // rule that holds the node back; both are empty when FP is the newest. Backup is the answer to a failed backup before // the apply, BackupAgain or BackupSkip (clause 16), empty when none was asked. Words say why in a sentence. The @@ -185,11 +186,18 @@ type Want struct { // is released. A put's want names no newest fingerprint or rule. Put bool `json:"put,omitempty"` Hold bool `json:"hold,omitempty"` + // Yes is the digest of a composition a person allowed the node to move to (novox/hq ADR 0547 §3): a move of a module + // of policy record that the node agent judged to recreate a container or move its data is applied only when its + // want names the composition so allowed. Empty when none was asked. + Yes string `json:"yes,omitempty"` + // Self says the node composes this module itself (ADR 0547 §2): the want names the build alone, and no composition. + // A want that is not Self names the one composition it allows (ADR 0539 clause 1), as before. + Self bool `json:"self,omitempty"` } func (w Want) check() error { if w.Removed { - if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold { + if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold || w.Self || w.Yes != "" { return fmt.Errorf("delivery: a removed want names no fingerprint, composition, rule, backup answer or put") } return nil @@ -203,11 +211,22 @@ func (w Want) check() error { if err := checkFingerprint(w.FP, "a want's fingerprint"); err != nil { return err } - if w.Composed == "" { - return fmt.Errorf("delivery: the want of %s names no composition: a want names the digest of the one composition it allows (ADR 0539 clause 1)", w.FP) + // From ADR 0547's second milestone a node the catalogue places composes its own modules (§2): its want is Self and + // names the build alone. Any other want names the one composition it allows (ADR 0539 clause 1). + switch { + case w.Self && w.Composed != "": + return fmt.Errorf("delivery: the want of %s says the node composes it, and names a composition", w.FP) + case !w.Self && w.Composed == "": + return fmt.Errorf("delivery: the want of %s names no composition: a want names the digest of the one composition it allows (ADR 0539 clause 1), unless the node composes it itself", w.FP) + case w.Composed != "": + if err := checkDigest(w.Composed, "a want's composition digest"); err != nil { + return err + } } - if err := checkDigest(w.Composed, "a want's composition digest"); err != nil { - return err + if w.Yes != "" { + if err := checkDigest(w.Yes, "the digest a want's yes allows"); err != nil { + return err + } } switch w.Backup { case "", BackupAgain, BackupSkip: @@ -309,11 +328,14 @@ type NodeWant struct { V int `json:"v"` Composed string `json:"composed"` Words string `json:"words,omitempty"` + // Self says the node composes its own composition (ADR 0547 §2) and the want names none. + Self bool `json:"self,omitempty"` } -// EncodeNodeWant is w as JSON at Version; it refuses a digest not of the sha256 form. +// EncodeNodeWant is w as JSON at Version; it refuses a digest not of the sha256 form, and a want that names no +// composition unless it says the node composes its own (Self, ADR 0547 §2). func EncodeNodeWant(w NodeWant) ([]byte, error) { - if err := checkDigest(w.Composed, "the node's own want's composition digest"); err != nil { + if err := w.check(); err != nil { return nil, err } w.V = Version @@ -332,12 +354,23 @@ func DecodeNodeWant(data []byte) (NodeWant, error) { if err := d.Decode(&w); err != nil { return NodeWant{}, fmt.Errorf("delivery: a node's own want does not fit its shape: %w", err) } - if err := checkDigest(w.Composed, "the node's own want's composition digest"); err != nil { + if err := w.check(); err != nil { return NodeWant{}, err } return w, nil } +// check refuses a node's own want that names no composition and does not say the node composes its own, or does both. +func (w NodeWant) check() error { + switch { + case w.Self && w.Composed != "": + return fmt.Errorf("delivery: the node's own want says the node composes it, and names a composition") + case w.Self: + return nil + } + return checkDigest(w.Composed, "the node's own want's composition digest") +} + // EncodeHeld is h as JSON at Version; it refuses a node name a key refuses. func EncodeHeld(h Held) ([]byte, error) { if err := h.check(); err != nil { diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index 5eecc31..d4f6288 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -48,7 +48,9 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "regexp" + "slices" "strings" "time" ) @@ -97,6 +99,10 @@ const ( // composition is applied, and the node waits for the answer in its want (novox/hq ADR 0539 clause 16). Detail // carries the backup's own words. PhaseHeld Phase = "held" + // PhaseWaitingYes is a module of policy record whose composed move the node agent judged to recreate a container or + // move its data (novox/hq ADR 0547 §3): nothing of it is applied, Judgement says the judgement and Waits the digest + // of the composition waiting, and the assigner asks a person; the node applies it once its want's Yes names Waits. + PhaseWaitingYes Phase = "waiting-yes" ) // Health is what the module's own check says. @@ -213,12 +219,15 @@ func parseModuleKeyPart(part string) (string, error) { return module, nil } +// HaveNodeWord is the last token of a node's own facts' key in HaveBucket. +const HaveNodeWord = nodeWord + // HaveNodeKey is the key, in HaveBucket, of a node's own facts: `.node`. func HaveNodeKey(node string) (string, error) { if err := checkName("node", node); err != nil { return "", err } - return node + ".node", nil + return node + "." + HaveNodeWord, nil } // ParseHaveKey is the inverse of HaveKey and HaveNodeKey: isNode is true for `.node`, with module empty. @@ -337,6 +346,15 @@ type Have struct { // UserList is what the node-engine of the node holding mesh-broker wrote of the bus's user list, in the have of the // bus module (novox/hq ADR 0547 §7, §8). Nil on every other have. UserList *UserListWritten `json:"user-list,omitempty"` + // Judgement and Waits are, in phase waiting-yes, the node agent's judgement of the move and the digest of the + // composition that waits for a person's yes (ADR 0547 §3); empty otherwise. + Judgement Judgement `json:"judgement,omitempty"` + Waits string `json:"waits,omitempty"` + // Sealed is, for a module the node composes itself, each value its running composition carries sealed to the node's + // key (a secret, a credential, a vault box), as applied (ADR 0547 §3, issue 1126): what the node's composer reuses + // after a restart while it opens to the vault's value, so a value sealed again moves no composition. Openable by the + // node alone, as the vault hands them over the bus. Empty on every other have. + Sealed []string `json:"sealed,omitempty"` } // UserListWritten is the bus's user list as the node-engine of the bus node wrote it to the bus module's file, applied @@ -381,6 +399,9 @@ type HaveNode struct { Strays json.RawMessage `json:"strays,omitempty"` Filters json.RawMessage `json:"filters,omitempty"` Profile json.RawMessage `json:"profile,omitempty"` + // Record is what the machine observes of itself, typed, for every node and composing part to read (issue 1126, + // ADR 0555 §3): the node's record lives in its own have key, so it needs no bucket, writer or grant of its own. + Record *NodeRecord `json:"record,omitempty"` } // Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman @@ -429,8 +450,18 @@ func decode(data []byte, into any) error { func (h Have) check() error { switch h.Phase { case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved, PhaseHeld: + if h.Judgement != "" || h.Waits != "" { + return fmt.Errorf("delivery: only a have waiting for a yes names a judgement and the composition waiting") + } + case PhaseWaitingYes: + if h.Judgement != JudgedRecreates && h.Judgement != JudgedMovesData && h.Judgement != JudgedCannot { + return fmt.Errorf("delivery: a have waiting for a yes names the judgement %q, not recreates, moves-data or cannot-judge", h.Judgement) + } + if err := checkDigest(h.Waits, "the composition a have waits a yes for"); err != nil { + return err + } default: - return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed or held", h.Phase) + return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed, held or waiting-yes", h.Phase) } if h.Composed != "" { if err := checkDigest(h.Composed, "the have's composition digest"); err != nil { @@ -509,7 +540,11 @@ func DecodePulse(data []byte) (Pulse, error) { func SameHave(a, b Have) bool { ua, ub := a.UserList, b.UserList a.UserList, b.UserList = nil, nil - if a != b || (ua == nil) != (ub == nil) { + if !slices.Equal(a.Sealed, b.Sealed) { + return false + } + a.Sealed, b.Sealed = nil, nil + if !reflect.DeepEqual(a, b) || (ua == nil) != (ub == nil) { return false } if ua == nil { @@ -524,7 +559,8 @@ func SameHave(a, b Have) bool { // SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing. func SameHaveNode(a, b HaveNode) bool { return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) && - sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) + sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) && + (a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record)) } // SamePresence says whether two presences are the same value. diff --git a/go/delivery/delivery_test.go b/go/delivery/delivery_test.go index 564e6ad..0ddd2a3 100644 --- a/go/delivery/delivery_test.go +++ b/go/delivery/delivery_test.go @@ -14,6 +14,9 @@ import ( // 1131/AC1: the have key forms and parses back; bad names are refused, naming them. func TestHaveKey(t *testing.T) { + t.Run("presence", testPresenceKey) + t.Run("pulse", testPulseKey) + t.Run("pulse ttl", testPulseTTL) if HaveBucket != "node-engine_have" { t.Fatalf("HaveBucket = %q", HaveBucket) } @@ -58,7 +61,7 @@ func TestHaveKey(t *testing.T) { } // 1131/AC2: the presence keys form and parse back; a node named part is refused. -func TestPresenceKey(t *testing.T) { +func testPresenceKey(t *testing.T) { if PresenceBucket != "mesh-delivery-watchman_presence" { t.Fatalf("PresenceBucket = %q", PresenceBucket) } @@ -98,7 +101,7 @@ func TestPresenceKey(t *testing.T) { } // 1131/AC3: the pulse key splits at the first dot, the key holding dots. -func TestPulseKey(t *testing.T) { +func testPulseKey(t *testing.T) { if PulseBucket != "mesh-delivery-watchman_pulse" { t.Fatalf("PulseBucket = %q", PulseBucket) } @@ -124,6 +127,7 @@ func TestPulseKey(t *testing.T) { // 1131/AC4: each value encodes, decodes equal, and carries "v":1. func TestRoundTrip(t *testing.T) { + t.Run("phase and health refused", testPhaseHealthRefused) hasV1 := func(t *testing.T, b []byte) { t.Helper() var m map[string]any @@ -149,7 +153,7 @@ func TestRoundTrip(t *testing.T) { t.Fatal(err) } in.V = Version - if out != in { + if !SameHave(out, in) { t.Fatalf("got %+v want %+v", out, in) } }) @@ -340,7 +344,7 @@ func TestUnknownVersion(t *testing.T) { } // 1131/AC6: a phase or health outside the lists is refused on encode and decode. -func TestPhaseHealthRefused(t *testing.T) { +func testPhaseHealthRefused(t *testing.T) { for _, p := range []Phase{PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed} { for _, h := range []Health{HealthOK, HealthFailed, HealthWaits, HealthUnknown} { if _, err := EncodeHave(Have{Phase: p, Health: h}); err != nil { @@ -363,10 +367,27 @@ func TestPhaseHealthRefused(t *testing.T) { if _, err := DecodeHave([]byte(`{"v":1,"phase":"running","health":"fine"}`)); err == nil { t.Error("bad health decoded") } + // ADR 0547 §3 (issue 1126): a have waits for a yes with its judgement and the composition waiting, and no other have + // names either. + waits := Have{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedRecreates, Waits: "sha256:" + strings.Repeat("a", 64)} + if raw, err := EncodeHave(waits); err != nil { + t.Errorf("a have waiting for a yes: %v", err) + } else if back, err := DecodeHave(raw); err != nil || back.Judgement != JudgedRecreates || back.Waits != waits.Waits { + t.Errorf("a have waiting for a yes came back %+v, %v", back, err) + } + for name, h := range map[string]Have{ + "neither judged": {Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedNeither, Waits: waits.Waits}, + "no waiting digest": {Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedMovesData}, + "running judged": {Phase: PhaseRunning, Health: HealthOK, Judgement: JudgedRecreates}, + } { + if _, err := EncodeHave(h); err == nil { + t.Errorf("%s: encoded", name) + } + } } // 1131/AC7: the pulse's time to live is 120 seconds minus the move's age; at the bound the move is stalled. -func TestPulseTTL(t *testing.T) { +func testPulseTTL(t *testing.T) { for _, c := range []struct { age time.Duration ttl time.Duration @@ -390,6 +411,7 @@ func TestPulseTTL(t *testing.T) { // 1131/AC8: equal values say same, different ones do not. func TestSame(t *testing.T) { + t.Run("have removed", testHaveRemoved) a := Have{V: 1, Build: "b", Phase: PhaseRunning, Health: HealthOK} b := a if !SameHave(a, b) { @@ -399,6 +421,17 @@ func TestSame(t *testing.T) { if SameHave(a, b) { t.Error("different haves same") } + // The sealed values a self-composing node runs are part of its have (issue 1126). + sa, sb := a, a + sa.Sealed, sb.Sealed = []string{`{"box":1}`}, []string{`{"box":1}`} + if !SameHave(sa, sb) || SameHave(sa, a) { + t.Error("equal sealed values differ, or one said and one not are the same") + } + if raw, err := EncodeHave(sa); err != nil { + t.Error(err) + } else if back, err := DecodeHave(raw); err != nil || !SameHave(back, sa) { + t.Errorf("sealed values do not travel: %+v, %v", back, err) + } // The user list is compared by what it says, never by where it is held (ADR 0547 §8). at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC) ua, ub := a, a @@ -443,7 +476,7 @@ func TestSame(t *testing.T) { } } -func TestHaveRemoved(t *testing.T) { // 1370/AC3 +func testHaveRemoved(t *testing.T) { // 1370/AC3 in := Have{Build: "b1", Phase: PhaseRemoved, Health: HealthUnknown, Detail: "the declaration no longer holds it"} b, err := EncodeHave(in) if err != nil { @@ -454,7 +487,7 @@ func TestHaveRemoved(t *testing.T) { // 1370/AC3 t.Fatal(err) } in.V = Version - if out != in || out.Phase != "removed" { + if !SameHave(out, in) || out.Phase != "removed" { t.Errorf("round trip: %+v", out) } } diff --git a/go/delivery/forge_inputs_test.go b/go/delivery/forge_inputs_test.go index e027ff7..802aa77 100644 --- a/go/delivery/forge_inputs_test.go +++ b/go/delivery/forge_inputs_test.go @@ -9,9 +9,10 @@ const oddManifest = "{\n\t\"module\": \"a\",\n \"z\": 1, \"a\": [ \"x\",\"y\" func mergedWithInputs() Forge { return Forge{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc", Root: "t0", - Trees: map[string]string{"a": "t1", "b": "t2"}, - Manifests: map[string]string{"a": oddManifest}, - Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}}} + Trees: map[string]string{"a": "t1", "b": "t2"}, + Manifests: map[string]string{"a": oddManifest}, + Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}}, + Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true} } // 1442/AC1 @@ -21,7 +22,7 @@ func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) { if err != nil { t.Fatal(err) } - for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`} { + for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`, `"placements":{"ace":`, `"placements-read":true`} { if !strings.Contains(string(data), frag) { t.Errorf("the encoding lacks %s: %s", frag, data) } @@ -47,6 +48,9 @@ func TestForgeMergedRefusesBadInputs(t *testing.T) { "locks outside trees": {func(f *Forge) { f.Locks["yy"] = map[string]string{"go.sum": "h"} }, `"yy"`}, "empty manifest": {func(f *Forge) { f.Manifests["b"] = "" }, `"b"`}, "empty lock hash": {func(f *Forge) { f.Locks["a"]["go.sum"] = "" }, `"a"`}, + "placements unsaid": {func(f *Forge) { f.PlacementsRead = false }, "placements-read"}, + "placement of no node": {func(f *Forge) { f.Placements["a.b"] = "{}" }, "names no node"}, + "placements on a pull": {func(f *Forge) { f.Kind, f.Action, f.Merge, f.Root, f.Trees = ForgePull, PullOpened, "", "", nil }, "only a merged message"}, } { f := mergedWithInputs() tc.mut(&f) diff --git a/go/delivery/judge.go b/go/delivery/judge.go new file mode 100644 index 0000000..f030401 --- /dev/null +++ b/go/delivery/judge.go @@ -0,0 +1,229 @@ +package delivery + +import ( + "bytes" + "encoding/json" + "path" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// The judgement of a move (ADR 0377 §3), as a pure function over two compositions of one module, so the node agent that +// composes its own modules judges what it would apply (novox/hq ADR 0547 §3; issue 1126's S8). Moved from +// mesh-controller cmd/mesh-controller/composed_judge.go at origin/main (the controller keeps its copy, unchanged, until +// it loses the lease, ADR 0547 §1): judged, onlyTheUserList and sameResources, with their helpers, are its code. +// +// ADR 0377 §3 over two compositions of one module: **recreates** when a container resource is changed, added or +// removed; **moves data** when, with no container changed, a directory or file resource is changed, added or removed +// that a container of the module mounts or that holds or lies within one of its directories (where a module keeps its +// data), when its `data` declaration changes, or, for a module that declares data, when one of its long-running +// processes changes; **neither** otherwise, and for a module the node has never run. + +// Judged is one move's judgement: what it does, its words, and whether only the bus's user list changes (ADR 0539 §7: +// a file change and a reload of the bus, no move of it, never asked). +type Judged struct { + Judgement Judgement + Words string + UserListOnly bool +} + +// JudgeMove judges moving a node's module from the composition it runs to the one composed for it. Running is nil when the +// node runs no composition of the module. A composition equal to the one running (resource for resource, each +// canonical) is unchanged: no move, and no ask (ADR 0547 §3). +func JudgeMove(running *Composed, composed Composed) Judged { + if composed.Module == "" { + return Judged{Judgement: JudgedNeither, Words: "the node's own composition"} + } + if running == nil { + return Judged{Judgement: JudgedNeither, Words: "the node runs no composition of " + composed.Module} + } + if running.Data == composed.Data && SameComposedResources(*running, composed) { + return Judged{Judgement: JudgedUnchanged, Words: "equal to the composition the node runs"} + } + if onlyTheUserList(*running, composed) { + return Judged{Judgement: JudgedNeither, Words: "only the bus's user list changes", UserListOnly: true} + } + j, words := judged(*running, composed) + return Judged{Judgement: j, Words: words} +} + +// busUsersSuffix ends the id of the bus module's user list as the composer writes it: `.bus-users`. +const busUsersSuffix = ".bus-users" + +// onlyTheUserList says two compositions of a module differ, and only in its file of the bus's user list. +func onlyTheUserList(from, to Composed) bool { + if from.Data != to.Data { + return false + } + before, after := judgedResourcesByID(from), judgedResourcesByID(to) + if len(before) != len(after) { + return false + } + changed := false + for id, now := range after { + was, ok := before[id] + if !ok { + return false + } + if bytes.Equal(was.raw, now.raw) { + continue + } + if !strings.HasSuffix(id, busUsersSuffix) || now.kind != string(declaration.TypeFile) { + return false + } + changed = true + } + return changed +} + +// judged applies ADR 0377 §3 to two compositions of one module. +func judged(from, to Composed) (Judgement, string) { + before, after := judgedResourcesByID(from), judgedResourcesByID(to) + if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 { + return JudgedRecreates, "it recreates " + strings.Join(ids, ", ") + } + if from.Data != to.Data { + return JudgedMovesData, "its data declaration changes" + } + places := dataPlaces(before, after) + held := func(kind string) bool { + return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile) + } + var moved []string + for _, id := range changedOf(before, after, held) { + r := before[id] + if r == nil { + r = after[id] + } + if withinPlaces(r.path, places) { + moved = append(moved, id) + } + } + if to.Data || from.Data { + moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...) + } + if len(moved) > 0 { + sort.Strings(moved) + return JudgedMovesData, "it changes where its data is kept: " + strings.Join(moved, ", ") + } + return JudgedNeither, "it recreates no container and moves no data" +} + +// judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount, +// and its canonical bytes. +type judgedResource struct { + kind string + path string + mounts []string + raw []byte +} + +func judgedResourcesByID(c Composed) map[string]*judgedResource { + out := map[string]*judgedResource{} + for _, raw := range c.Resources { + var head struct { + ID string `json:"id"` + Type string `json:"type"` + Path string `json:"path"` + // A container's volumes, `:[:]`. + Volumes []string `json:"volumes"` + } + if err := json.Unmarshal(raw, &head); err != nil { + continue + } + r := &judgedResource{kind: head.Type, path: head.Path, raw: compactResource(raw)} + for _, v := range head.Volumes { + source, _, _ := strings.Cut(v, ":") + r.mounts = append(r.mounts, source) + } + out[head.ID] = r + } + return out +} + +// compactResource is a resource's bytes canonical: its keys sorted, its spacing dropped, its numbers as written, so two +// compositions that say the same compare equal however each was written. +func compactResource(raw []byte) []byte { + var v any + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if err := d.Decode(&v); err != nil { + return raw + } + out, err := json.Marshal(v) + if err != nil { + return raw + } + return out +} + +// changedOf are the ids of the resources of the kinds counted that are changed, added or removed, sorted. +func changedOf(before, after map[string]*judgedResource, counted func(kind string) bool) []string { + var out []string + for id, now := range after { + if !counted(now.kind) { + continue + } + if was, ok := before[id]; !ok || !bytes.Equal(was.raw, now.raw) { + out = append(out, id) + } + } + for id, was := range before { + if _, ok := after[id]; !ok && counted(was.kind) { + out = append(out, id) + } + } + sort.Strings(out) + return out +} + +// dataPlaces are the paths where the module keeps what a move must not lose: what its containers mount, and its +// directories, before and after. +func dataPlaces(before, after map[string]*judgedResource) []string { + var out []string + for _, rs := range []map[string]*judgedResource{before, after} { + for _, r := range rs { + if r.kind == string(declaration.TypeContainer) { + out = append(out, r.mounts...) + } + if r.kind == string(declaration.TypeDirectory) && r.path != "" { + out = append(out, r.path) + } + } + } + return out +} + +// withinPlaces says p is one of places, or lies within one, or holds one. +func withinPlaces(p string, places []string) bool { + if p == "" { + return false + } + p = path.Clean(p) + for _, place := range places { + if place == "" || !strings.HasPrefix(place, "/") { + continue + } + place = path.Clean(place) + if p == place || strings.HasPrefix(p, place+"/") || strings.HasPrefix(place, p+"/") { + return true + } + } + return false +} + +// SameComposedResources says two compositions of a module hold the same resources in the same order, each compared +// canonical. +func SameComposedResources(a, b Composed) bool { + if len(a.Resources) != len(b.Resources) { + return false + } + for i := range a.Resources { + if !bytes.Equal(compactResource(a.Resources[i]), compactResource(b.Resources[i])) { + return false + } + } + return true +} diff --git a/go/delivery/judge_test.go b/go/delivery/judge_test.go new file mode 100644 index 0000000..4e61fd5 --- /dev/null +++ b/go/delivery/judge_test.go @@ -0,0 +1,68 @@ +package delivery + +import ( + "encoding/json" + "strings" + "testing" +) + +func composedOf(module string, c byte, data bool, resources ...string) Composed { + out := Composed{Node: "ace", Module: module, FP: SourcePrefix2 + strings.Repeat(string(c), 64), Data: data} + for _, r := range resources { + out.Resources = append(out.Resources, json.RawMessage(r)) + } + return out +} + +// The pairs mesh-controller's composed_judge_test.go captured (issue 1945 AC12), and the bus's user list, judged as the +// controller judges them (ADR 0377 §3, ADR 0539 §7), by the node agent's function (ADR 0547 §3, issue 1126 S8). +func TestJudgeMove(t *testing.T) { + const ( + container = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:1111111111111111111111111111111111111111111111111111111111111111","volumes":["/var/lib/mesh-mail/spool:/spool"]}` + container2 = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:2222222222222222222222222222222222222222222222222222222222222222","volumes":["/var/lib/mesh-mail/spool:/spool"]}` + spool = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}` + spoolMoved = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/srv/mesh-mail/spool"}` + tools = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"a"}` + tools2 = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"b"}` + config = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"a"}` + config2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}` + process = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/a"}` + process2 = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/b"}` + users = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"a"}` + users2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"b"}` + natsConf = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"a"}` + natsConf2 = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"b"}` + ) + mail := func(c byte, data bool, rs ...string) *Composed { + m := composedOf("mesh-mail", c, data, rs...) + return &m + } + bus := func(rs ...string) *Composed { m := composedOf("nats", 'b', false, rs...); return &m } + for _, c := range []struct { + name string + from, to *Composed + want Judgement + userList bool + }{ + {"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false}, + {"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false}, + {"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false}, + {"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false}, + {"its data declaration", mail('a', false, container, spool), mail('b', true, container, spool), JudgedMovesData, false}, + {"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false}, + {"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false}, + {"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false}, + {"never ran", nil, mail('b', true, container), JudgedNeither, false}, + {"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false}, + {"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true}, + {"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false}, + } { + got := JudgeMove(c.from, *c.to) + if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" { + t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList) + } + } + if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither { + t.Errorf("the node's own composition: %+v", got) + } +} diff --git a/go/delivery/noderecord.go b/go/delivery/noderecord.go new file mode 100644 index 0000000..c22f8b2 --- /dev/null +++ b/go/delivery/noderecord.go @@ -0,0 +1,98 @@ +package delivery + +import ( + "encoding/json" + "fmt" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A node's record is what the machine observes of itself (issue 1126, ADR 0555 §3), written by its own agent in its +// own have key, `.node` of the node-engine's have bucket (HaveNode.Record), whenever an observed fact changes and +// only when the value differs. The key's change is the event its readers hear: every node and the parts that compose +// watch it. Where a module runs, and every fact a person decides of the node (its endpoint, site, hub, address on the +// private network, public domain, accounts, the tunnel found at adoption), are the catalogue's placement +// (placement.go). Nothing in it is secret: its keys are public halves. + +// RecordTunnel is a tunnel found on a node when it was adopted, as compose.Tunnel holds it: public keys and addresses +// only. After the node took it over the tunnel is no longer there to observe, so it is kept as what was decided at +// adoption, in the node's placement (NodeDecided.Tunnel). +type RecordTunnel struct { + Interface string `json:"interface"` + Unit string `json:"unit,omitempty"` + Config string `json:"config,omitempty"` + Port int `json:"port,omitempty"` + MTU int `json:"mtu,omitempty"` + Address string `json:"address,omitempty"` + Range string `json:"range,omitempty"` + PublicKey string `json:"public-key,omitempty"` + Peers []RecordTunnelPeer `json:"peers,omitempty"` +} + +// RecordTunnelPeer is one peer of a found tunnel. +type RecordTunnelPeer struct { + PublicKey string `json:"public-key"` + Address string `json:"address,omitempty"` +} + +// NodeRecord is one node's record: what the machine observes of itself (issue 1126, ADR 0555: what a person decides +// lives in the catalogue's placement file, Placement.Decided; what the machine observes lives here). TunnelKey is the +// public key of its private-network tunnel; Certificate the one the mesh issued for its internal name, public, as the +// node holds it. The tunnel found at adoption and the names given to its peers are decisions kept in the placement; +// whether the hub took its found tunnel over, and which node enrolled under a carried peer's key, are read from both. +type NodeRecord struct { + V int `json:"v"` + Node string `json:"node"` + TunnelKey string `json:"tunnel-key,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + SealingKey string `json:"sealing-key,omitempty"` + ServingKey string `json:"serving-key,omitempty"` + Certificate string `json:"certificate,omitempty"` + OutwardLinks []string `json:"outward-links,omitempty"` + Networks []catalogue.AttachedNetwork `json:"networks,omitempty"` + NetworksSaid bool `json:"networks-said,omitempty"` + Contract int `json:"contract,omitempty"` + // Authority is the mesh's own certificate as this machine holds it (public): the one its declaration last wrote where + // a module asked for it. A node composing its own composes a certificate with it (compose.Input.Authority). + Authority string `json:"authority,omitempty"` +} + +func (r NodeRecord) check() error { + if err := checkName("node", r.Node); err != nil { + return err + } + for _, c := range r.Capabilities { + if c == "" { + return fmt.Errorf("delivery: the record of %s names an empty capability", r.Node) + } + } + return nil +} + +// EncodeNodeRecord is r as JSON at Version; it refuses what DecodeNodeRecord refuses. +func EncodeNodeRecord(r NodeRecord) ([]byte, error) { + if err := r.check(); err != nil { + return nil, err + } + r.V = Version + return json.Marshal(r) +} + +// DecodeNodeRecord reads a node record; it refuses an unknown version (ErrUnknownVersion), a node that is not a name, and +// an empty capability. +func DecodeNodeRecord(data []byte) (NodeRecord, error) { + var r NodeRecord + if err := decode(data, &r); err != nil { + return NodeRecord{}, err + } + if err := r.check(); err != nil { + return NodeRecord{}, err + } + return r, nil +} + +// SameNodeRecord says whether two records are the same value, so the agent writes only when they differ. +func SameNodeRecord(a, b NodeRecord) bool { + a.V, b.V = 0, 0 + return jsonEqual(a, b) +} diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go new file mode 100644 index 0000000..e49b327 --- /dev/null +++ b/go/delivery/noderecord_test.go @@ -0,0 +1,73 @@ +package delivery + +import ( + "reflect" + "regexp" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// secretNamed is facts.secretKey's pattern: a field so named would read as a secret, and the runtime's credential-field +// check (ADR 0201 §13) refuses such a field in a state every node reads. +var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`) + +// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and +// neither it nor the placement file names a field like a secret. +func TestNodeRecord(t *testing.T) { + hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub", + Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, + NetworksSaid: true, Contract: 3} + raw, err := EncodeNodeRecord(hub) + if err != nil { + t.Fatal(err) + } + back, err := DecodeNodeRecord(raw) + if err != nil || !SameNodeRecord(back, hub) || back.V != Version { + t.Fatalf("round trip: %+v, %v", back, err) + } + // It rides in the node's own have key: a have that differs only in its record is another value. + with, without := HaveNode{V: Version, Record: &hub}, HaveNode{V: Version} + if raw, err := EncodeHaveNode(with); err != nil { + t.Fatal(err) + } else if got, err := DecodeHaveNode(raw); err != nil || got.Record == nil || !SameHaveNode(got, with) || SameHaveNode(got, without) { + t.Fatalf("the have's record: %+v, %v", got, err) + } + changed := hub + changed.Contract = 4 + if SameHaveNode(with, HaveNode{V: Version, Record: &changed}) { + t.Error("a changed record reads as the same have") + } + + for name, r := range map[string]NodeRecord{ + "empty capability": {Node: "ace", Capabilities: []string{""}}, + "node that is not a name": {Node: "a.b"}, + } { + if _, err := EncodeNodeRecord(r); err == nil { + t.Errorf("%s: taken", name) + } + } + + // Every JSON field name of the record and the types it carries, walked: none reads as a secret. + var walk func(reflect.Type, string) + walk = func(ty reflect.Type, at string) { + for ty.Kind() == reflect.Pointer || ty.Kind() == reflect.Slice { + ty = ty.Elem() + } + if ty.Kind() != reflect.Struct { + return + } + for i := 0; i < ty.NumField(); i++ { + f := ty.Field(i) + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if secretNamed.MatchString(name) || secretNamed.MatchString(f.Name) { + t.Errorf("%s.%s (%q) is named like a secret", at, f.Name, name) + } + walk(f.Type, at+"."+f.Name) + } + } + walk(reflect.TypeOf(NodeRecord{}), "NodeRecord") + // And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential. + walk(reflect.TypeOf(Placement{}), "Placement") +} diff --git a/go/delivery/placement.go b/go/delivery/placement.go new file mode 100644 index 0000000..99ed5de --- /dev/null +++ b/go/delivery/placement.go @@ -0,0 +1,464 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "path" + "sort" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Placement of modules on nodes lives in the catalogue (novox/hq ADR 0549): one file per node, `placement/.json` +// in the catalogue repository, changed only by a pull request. At each trunk merge of the catalogue the indexer reads +// the files and writes each node's key `placement.` in its state `placement`, only when it differs, and says +// `placement-changed` naming the node, the revision and the modules added, removed or changed (ADR 0549 §2). The node +// agents, the assigner, the grants part and mesh-config read it (§3); nothing in it is secret. +const ( + // PlacementState is the indexer's state of placements, and PlacementBucket its bucket. + PlacementState = "placement" + PlacementBucket = IndexerModule + "_" + PlacementState + // PlacementHistory is the revisions the placement bucket keeps per key. + PlacementHistory = 10 + // PlacementDir is the catalogue's directory of placement files, one `.json` per node. + PlacementDir = "placement" + // PlacementChangedEvent is what the indexer says when a node's placement key changed. + PlacementChangedEvent = "placement-changed" + + placementWord = "placement" + + // CatalogueRepository is the repository whose placement files are the mesh's placement (ADR 0549 §1): the forge bridge + // reads them at each of its merges, and no other repository's. + CatalogueRepository = "novox/mesh-catalog" +) + +// PlacementKey is the key, in PlacementBucket, of one node's placement: `placement.`. +func PlacementKey(node string) (string, error) { + if err := checkName("node", node); err != nil { + return "", err + } + return placementWord + "." + node, nil +} + +// ParsePlacementKey is the inverse of PlacementKey; it refuses any other key, naming it. +func ParsePlacementKey(key string) (node string, err error) { + node, ok := strings.CutPrefix(key, placementWord+".") + if !ok || checkName("node", node) != nil { + return "", fmt.Errorf("delivery: %q is not a placement key, placement.", key) + } + return node, nil +} + +// PlacementFile is the path, in the catalogue repository, of one node's placement file. +func PlacementFile(node string) string { return path.Join(PlacementDir, node+".json") } + +// PlacementFileNode is the node a catalogue path is the placement file of, and whether it is one: `placement/.json` +// directly under PlacementDir. +func PlacementFileNode(p string) (string, bool) { + dir, file := path.Split(p) + node, ok := strings.CutSuffix(file, ".json") + if path.Clean(dir) != PlacementDir || !ok || checkName("node", node) != nil { + return "", false + } + return node, true +} + +// Provider is the module on a node that answers a requirement or holds a seat: a pin's or a binding's provider. +type Provider struct { + Node string `json:"node"` + Module string `json:"module"` +} + +// Chosen is p as the composition names a chosen provider. +func (p Provider) Chosen() catalogue.Chosen { return catalogue.Chosen{Node: p.Node, Module: p.Module} } + +// PlacedModule is one module placed on the node: the host port each port it declares is published on (by the port the +// software listens on), and, by provision, the provider each of its requirements that keeps data is bound to. +// +// MeshWide and Layer are where the module's things are, as a settings layer's placement holds them (catalogue.Layer's +// Placement: `places`, `accesses`, `networks`, `endpoints`, `expose`, `reach`, `ports`; ADR 0348 §11): the whole mesh's +// layer and this node's, which the controller keeps in its table `placement` (ADR 0549 §6 names it) and composes as two +// layers, the mesh's first. Each node's file repeats the mesh-wide layer of the modules placed on it, so a node reads +// its own key alone. HasLayer says the node has a layer of its own even when it holds no placement, since a layer +// that is there and empty still carries the module's config values (Layers). +// +// MeshValues and Values are the config values of a module in the settings form (its manifest declares no config, so +// mesh-config holds none for it): what a person set in the whole mesh's layer and this node's, beside the placement +// (issue 1126; on 2026-10-26 the media modules on one node, plex's puid and pgid among them). A module in the config +// form takes its values from mesh-config and has none here. +type PlacedModule struct { + Module string `json:"module"` + Ports map[string]int `json:"ports,omitempty"` + Bindings map[string]Provider `json:"bindings,omitempty"` + MeshWide map[string]any `json:"mesh-wide,omitempty"` + HasMesh bool `json:"has-mesh-wide,omitempty"` + Layer map[string]any `json:"layer,omitempty"` + HasLayer bool `json:"has-layer,omitempty"` + MeshValues map[string]any `json:"mesh-wide-values,omitempty"` + Values map[string]any `json:"values,omitempty"` +} + +// Holding is one seat a module placed on the node holds (catalogue.Held without its node, which is the file's). +type Holding struct { + Claim string `json:"claim"` + Scope string `json:"scope,omitempty"` + Module string `json:"module"` + Site string `json:"site,omitempty"` + Kind string `json:"kind,omitempty"` +} + +// Placement is one node's placement: the file the catalogue keeps and, with Revision, the value of its key. Modules are +// in the order the file names them, which is the order the composition reads the node's modules in. Pins is, by +// provision, the provider chosen for the node where more than one could answer. +type Placement struct { + V int `json:"v"` + Node string `json:"node"` + Revision string `json:"revision,omitempty"` + Decided NodeDecided `json:"decided"` + Modules []PlacedModule `json:"modules"` + Pins map[string]Provider `json:"pins,omitempty"` + Holds []Holding `json:"holds,omitempty"` +} + +// NodeDecided is what a person decides of the node, kept in its placement file (issue 1126's K2: what a person decides +// lives in the catalogue, what the machine observes lives in its record, NodeRecord): where it is dialled (endpoint) and +// its site, whether it is the private network's hub, its address on the private network, the domain its routed names +// are composed under, its operator and agent logins by name and their homes, whether it is adopted rather than +// converged, the modules taken on an adopted node, the tunnel found on it at adoption and, on the hub, the names given +// to that tunnel's peers. The address is a decision written once, at enrolment, by a +// catalogue pull request: nothing allocates it at run time, and CheckPlacements refuses two nodes with one address. +// No secret and no key material: accounts are names, never credentials. +type NodeDecided struct { + Endpoint string `json:"endpoint,omitempty"` + Site string `json:"site,omitempty"` + Hub bool `json:"hub,omitempty"` + Address string `json:"address,omitempty"` + PublicDomain string `json:"public-domain,omitempty"` + Account string `json:"account,omitempty"` + AccountHome string `json:"account-home,omitempty"` + AgentAccount string `json:"agent-account,omitempty"` + AgentHome string `json:"agent-account-home,omitempty"` + Adopted bool `json:"adopted,omitempty"` + Taken []string `json:"taken,omitempty"` + // Tunnel is the tunnel found on the node when it was adopted, kept as decided then: once the node takes it over it is + // no longer there to observe. PeerNames is, on the hub, the name a person gave a peer of that tunnel, by its public key. + Tunnel *RecordTunnel `json:"tunnel,omitempty"` + PeerNames map[string]string `json:"peer-names,omitempty"` +} + +func (p Placement) check() error { + if err := checkName("node", p.Node); err != nil { + return err + } + seen := map[string]bool{} + for _, m := range p.Modules { + if err := checkName("module", m.Module); err != nil { + return err + } + if seen[m.Module] { + return fmt.Errorf("delivery: the placement of %s names %s twice", p.Node, m.Module) + } + seen[m.Module] = true + for port, at := range m.Ports { + if n, err := strconv.Atoi(port); err != nil || n <= 0 || n > 65535 { + return fmt.Errorf("delivery: %s's port %q on %s is not a port number", m.Module, port, p.Node) + } + if at <= 0 || at > 65535 { + return fmt.Errorf("delivery: %s's port %s on %s is published on %d, which is not a port number", m.Module, port, p.Node, at) + } + } + for provision, to := range m.Bindings { + if provision == "" || to.Node == "" || to.Module == "" { + return fmt.Errorf("delivery: %s on %s has a binding with no provision, node or module", m.Module, p.Node) + } + } + } + for provision, to := range p.Pins { + if provision == "" || to.Node == "" || to.Module == "" { + return fmt.Errorf("delivery: the placement of %s has a pin with no provision, node or module", p.Node) + } + } + for _, h := range p.Holds { + if h.Claim == "" || !seen[h.Module] { + return fmt.Errorf("delivery: the placement of %s has %s hold %q, which is not a module placed there or names no seat", p.Node, h.Module, h.Claim) + } + } + return nil +} + +// EncodePlacement is p as JSON at Version; it refuses what DecodePlacement refuses. +func EncodePlacement(p Placement) ([]byte, error) { + if err := p.check(); err != nil { + return nil, err + } + p.V = Version + return json.Marshal(p) +} + +// DecodePlacement reads a placement file or key value; it refuses an unknown version (ErrUnknownVersion), a node or +// module that is not a name, a module named twice, a port that is not a number, a binding or pin naming no provider, +// and a holding by a module not placed on the node. +func DecodePlacement(data []byte) (Placement, error) { + var p Placement + if err := decode(data, &p); err != nil { + return Placement{}, err + } + if err := p.check(); err != nil { + return Placement{}, err + } + return p, nil +} + +// SamePlacement says whether two placements are the same value, so the indexer writes only when they differ. The +// revision is not compared: a merge that changes no placement writes nothing (ADR 0549 §2). +func SamePlacement(a, b Placement) bool { + a.Revision, b.Revision = "", "" + ja, errA := EncodePlacement(a) + jb, errB := EncodePlacement(b) + return errA == nil && errB == nil && string(ja) == string(jb) +} + +// PlacementChange is what `placement-changed` says: the node, the catalogue revision, and its modules added, removed +// or changed against the placement before. +type PlacementChange struct { + Node string `json:"node"` + Revision string `json:"revision"` + Added []string `json:"added,omitempty"` + Removed []string `json:"removed,omitempty"` + Changed []string `json:"changed,omitempty"` +} + +// ChangeOf is what changed from before to after on one node; before is the zero Placement for a node new to placement. +// A change of the node's pins or holdings counts as a change of every module placed on it both times, since the +// composition of each may read them. +func ChangeOf(before, after Placement) PlacementChange { + c := PlacementChange{Node: after.Node, Revision: after.Revision} + if c.Node == "" { + c.Node = before.Node + } + was := map[string]PlacedModule{} + for _, m := range before.Modules { + was[m.Module] = m + } + nodeWide := !jsonEqual(before.Pins, after.Pins) || !jsonEqual(before.Holds, after.Holds) + now := map[string]bool{} + for _, m := range after.Modules { + now[m.Module] = true + old, ok := was[m.Module] + switch { + case !ok: + c.Added = append(c.Added, m.Module) + case nodeWide || !jsonEqual(old, m): + c.Changed = append(c.Changed, m.Module) + } + } + for _, m := range before.Modules { + if !now[m.Module] { + c.Removed = append(c.Removed, m.Module) + } + } + sort.Strings(c.Added) + sort.Strings(c.Removed) + sort.Strings(c.Changed) + return c +} + +func jsonEqual(a, b any) bool { + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + return string(ja) == string(jb) +} + +// Assigned is the modules placed on the node, in the file's order (compose.Node's Assigned). +func (p Placement) Assigned() []string { + out := make([]string, 0, len(p.Modules)) + for _, m := range p.Modules { + out = append(out, m.Module) + } + return out +} + +// ChosenPins is the node's pins as the composition reads them (compose.Node's Pins); nil when it has none. +func (p Placement) ChosenPins() map[string]catalogue.Chosen { + if len(p.Pins) == 0 { + return nil + } + out := make(map[string]catalogue.Chosen, len(p.Pins)) + for provision, to := range p.Pins { + out[provision] = to.Chosen() + } + return out +} + +// Bound is, by consumer module and provision, where each binding sends it (compose.Node's Bound); nil when none. +func (p Placement) Bound() map[string]map[string]catalogue.Chosen { + var out map[string]map[string]catalogue.Chosen + for _, m := range p.Modules { + if len(m.Bindings) == 0 { + continue + } + if out == nil { + out = map[string]map[string]catalogue.Chosen{} + } + out[m.Module] = make(map[string]catalogue.Chosen, len(m.Bindings)) + for provision, to := range m.Bindings { + out[m.Module][provision] = to.Chosen() + } + } + return out +} + +// HostPorts is, by module and the port it declares, the host port it is published on (compose.Node's Ports); nil when +// none. The check has refused a port that is not a number, so none is dropped here. +func (p Placement) HostPorts() map[string]map[int]int { + var out map[string]map[int]int + for _, m := range p.Modules { + if len(m.Ports) == 0 { + continue + } + if out == nil { + out = map[string]map[int]int{} + } + out[m.Module] = make(map[int]int, len(m.Ports)) + for port, at := range m.Ports { + n, _ := strconv.Atoi(port) + out[m.Module][n] = at + } + } + return out +} + +// Layers is one module's settings layers on the node, as the composition reads them (compose.Node's Settings): the +// whole mesh's layer when it has one, then the node's, which carries the module's config values as mesh-config resolved +// them for this node (values, nil when none) beside its placement; a module with no layer and no value has none. This is +// the controller's SettingsFor for a module that takes its config values from mesh-config (issue 664). A module in the +// settings form carries its values in the file (MeshValues, Values), which then stand for mesh-config's. +func (m PlacedModule) Layers(node string, values map[string]any) []catalogue.Layer { + var out []catalogue.Layer + if m.HasMesh || len(m.MeshWide) > 0 || len(m.MeshValues) > 0 { + mesh := map[string]any{} + for k, v := range m.MeshValues { + mesh[k] = v + } + out = append(out, catalogue.Layer{From: catalogue.MeshWideLayer, Values: mesh, Placement: m.MeshWide}) + } + if len(m.Values) > 0 { + values = make(map[string]any, len(m.Values)) + for k, v := range m.Values { + values[k] = v + } + } + if m.HasLayer || len(m.Layer) > 0 || len(values) > 0 { + if values == nil { + values = map[string]any{} + } + out = append(out, catalogue.Layer{From: node, Values: values, Placement: m.Layer}) + } + return out +} + +// Held is the node's holdings as the composition reads them (compose.Input's Holdings rows for this node). +func (p Placement) Held() []catalogue.Held { + out := make([]catalogue.Held, 0, len(p.Holds)) + for _, h := range p.Holds { + out = append(out, catalogue.Held{Claim: h.Claim, Scope: h.Scope, Node: p.Node, Module: h.Module, Site: h.Site, Kind: h.Kind}) + } + return out +} + +// CheckPlacements is the catalogue's own check of every placement file (ADR 0549 §1), over the placements by node, the +// modules the catalogue holds and the nodes enrolled. It returns every refusal, in node order, never only the first: a +// placement naming a node not enrolled or a module the catalogue does not hold; two modules of one node on the same host +// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice; +// two nodes with one address on the private network; more than one hub. +// A host port a manifest declares without a number and a requirement left unbound are the composition's to find (its +// ForeseenPorts and Unbound), which the catalogue's composition check reads. +func CheckPlacements(placements map[string]Placement, held func(module string) bool, enrolled func(node string) bool) []string { + var refusals []string + nodes := make([]string, 0, len(placements)) + for n := range placements { + nodes = append(nodes, n) + } + sort.Strings(nodes) + placed := func(p Provider) bool { + other, ok := placements[p.Node] + if !ok { + return false + } + for _, m := range other.Modules { + if m.Module == p.Module { + return true + } + } + return false + } + meshSeats := map[string]string{} + addresses := map[string]string{} + var hubs []string + for _, node := range nodes { + p := placements[node] + if p.Node != node { + refusals = append(refusals, fmt.Sprintf("the placement file of %s names the node %q", node, p.Node)) + } + if a := strings.TrimSpace(p.Decided.Address); a != "" { + if other, twice := addresses[a]; twice { + refusals = append(refusals, fmt.Sprintf("%s and %s both have the address %s on the private network", other, node, a)) + } + addresses[a] = node + } + if p.Decided.Hub { + hubs = append(hubs, node) + } + if !enrolled(node) { + refusals = append(refusals, fmt.Sprintf("%s is not an enrolled node", node)) + } + hostPorts := map[int]string{} + for _, m := range p.Modules { + if !held(m.Module) { + refusals = append(refusals, fmt.Sprintf("%s: the catalogue holds no module %s", node, m.Module)) + } + for _, port := range sortedKeys(m.Ports) { + at := m.Ports[port] + if other, clash := hostPorts[at]; clash && other != m.Module { + refusals = append(refusals, fmt.Sprintf("%s: %s and %s are both published on host port %d", node, other, m.Module, at)) + } + hostPorts[at] = m.Module + } + for _, provision := range sortedKeys(m.Bindings) { + if to := m.Bindings[provision]; !placed(to) { + refusals = append(refusals, fmt.Sprintf("%s: %s's %s is bound to %s/%s, which is not placed there", node, m.Module, provision, to.Node, to.Module)) + } + } + } + for _, provision := range sortedKeys(p.Pins) { + if to := p.Pins[provision]; !placed(to) { + refusals = append(refusals, fmt.Sprintf("%s: the pin of %s names %s/%s, which is not placed there", node, provision, to.Node, to.Module)) + } + } + for _, h := range p.Holds { + if h.Scope != "mesh" { + continue + } + seat := h.Claim + "/" + h.Kind + if other, twice := meshSeats[seat]; twice { + refusals = append(refusals, fmt.Sprintf("the seat %s, held once for the mesh, is held on %s and %s", h.Claim, other, node)) + } + meshSeats[seat] = node + } + } + if len(hubs) > 1 { + refusals = append(refusals, fmt.Sprintf("the private network has one hub, and %s are each placed as it", strings.Join(hubs, ", "))) + } + return refusals +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go new file mode 100644 index 0000000..3993556 --- /dev/null +++ b/go/delivery/placement_test.go @@ -0,0 +1,172 @@ +package delivery + +import ( + "errors" + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// ADR 0549 §1, §2: the placement key and file, the value's refusals, its reading as the composition reads a node, what +// a change says, and the catalogue's check of every file. +func TestPlacement(t *testing.T) { + t.Run("key and file", func(t *testing.T) { + key, err := PlacementKey("g14") + if err != nil || key != "placement.g14" { + t.Fatalf("PlacementKey = %q, %v", key, err) + } + if n, err := ParsePlacementKey(key); err != nil || n != "g14" { + t.Fatalf("ParsePlacementKey = %q, %v", n, err) + } + for _, bad := range []string{"want.g14.x", "placement.", "placement.a.b"} { + if _, err := ParsePlacementKey(bad); err == nil { + t.Errorf("ParsePlacementKey(%q) took it", bad) + } + } + if _, err := PlacementKey("a.b"); err == nil { + t.Error("PlacementKey took a dotted node") + } + if PlacementBucket != "mesh-delivery-indexer_placement" || PlacementFile("ace") != "placement/ace.json" { + t.Fatalf("bucket %q, file %q", PlacementBucket, PlacementFile("ace")) + } + for p, want := range map[string]string{"placement/ace.json": "ace", "./placement/ace.json": "ace", + "placement/x/ace.json": "", "modules/ace.json": "", "placement/ace.yaml": ""} { + if n, ok := PlacementFileNode(p); n != want || ok != (want != "") { + t.Errorf("PlacementFileNode(%q) = %q, %v", p, n, ok) + } + } + }) + + g14 := Placement{Node: "g14", Revision: "abc", + Decided: NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}}, + Modules: []PlacedModule{ + {Module: "zsh"}, + {Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "novox", Module: "postgres"}}}, + }, + Pins: map[string]Provider{"shell": {Node: "g14", Module: "zsh"}}, + Holds: []Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}}, + } + novox := Placement{Node: "novox", Decided: NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []PlacedModule{{Module: "postgres", Ports: map[string]int{"5432": 5432}}}, + Holds: []Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}} + + t.Run("value", func(t *testing.T) { + raw, err := EncodePlacement(g14) + if err != nil { + t.Fatal(err) + } + back, err := DecodePlacement(raw) + if err != nil || back.V != Version || !SamePlacement(back, g14) { + t.Fatalf("round trip: %+v, %v", back, err) + } + moved := g14 + moved.Revision = "def" + if !SamePlacement(g14, moved) { + t.Error("a new revision alone counts as a change") + } + if _, err := DecodePlacement([]byte(`{"v":9,"node":"g14","modules":[]}`)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("version 9: %v", err) + } + for name, bad := range map[string]string{ + "module twice": `{"v":1,"node":"g14","modules":[{"module":"a"},{"module":"a"}]}`, + "port not number": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"http":80}}]}`, + "host port zero": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"80":0}}]}`, + "binding no node": `{"v":1,"node":"g14","modules":[{"module":"a","bindings":{"pg":{"module":"pg"}}}]}`, + "pin no module": `{"v":1,"node":"g14","modules":[],"pins":{"pg":{"node":"x"}}}`, + "hold not placed": `{"v":1,"node":"g14","modules":[],"holds":[{"claim":"s","module":"a"}]}`, + "node not a name": `{"v":1,"node":"g.14","modules":[]}`, + "value not shaped": `{"v":1,"node":"g14","modules":{}}`, + } { + if _, err := DecodePlacement([]byte(bad)); err == nil { + t.Errorf("%s: taken", name) + } + } + }) + + t.Run("as the composition reads it", func(t *testing.T) { + if got := g14.Assigned(); !reflect.DeepEqual(got, []string{"zsh", "app"}) { + t.Errorf("Assigned = %v, want the file's order", got) + } + if got := g14.ChosenPins(); !reflect.DeepEqual(got, map[string]catalogue.Chosen{"shell": {Node: "g14", Module: "zsh"}}) { + t.Errorf("ChosenPins = %v", got) + } + if got := g14.Bound(); !reflect.DeepEqual(got, map[string]map[string]catalogue.Chosen{"app": {"postgres": {Node: "novox", Module: "postgres"}}}) { + t.Errorf("Bound = %v", got) + } + if got := g14.HostPorts(); !reflect.DeepEqual(got, map[string]map[int]int{"app": {8080: 18080}}) { + t.Errorf("HostPorts = %v", got) + } + if got := g14.Held(); !reflect.DeepEqual(got, []catalogue.Held{{Claim: "node-shell", Scope: "node", Node: "g14", Module: "zsh"}}) { + t.Errorf("Held = %v", got) + } + layered := PlacedModule{Module: "app", HasMesh: true, Layer: map[string]any{"expose": map[string]any{"80": "mesh"}}} + want := []catalogue.Layer{ + {From: catalogue.MeshWideLayer, Values: map[string]any{}}, + {From: "g14", Values: map[string]any{"mode": "x"}, Placement: map[string]any{"expose": map[string]any{"80": "mesh"}}}, + } + if got := layered.Layers("g14", map[string]any{"mode": "x"}); !reflect.DeepEqual(got, want) { + t.Errorf("Layers = %#v, want %#v", got, want) + } + if got := (PlacedModule{Module: "a"}).Layers("g14", map[string]any{"k": 1}); len(got) != 1 || got[0].From != "g14" { + t.Errorf("values alone make the node's layer: %#v", got) + } + if got := (PlacedModule{Module: "a", HasLayer: true}).Layers("g14", nil); len(got) != 1 || got[0].Values == nil { + t.Errorf("an empty layer of the node's own is kept, with empty values: %#v", got) + } + if got := (PlacedModule{Module: "a"}).Layers("g14", nil); got != nil { + t.Errorf("no layer and no value is no layer: %#v", got) + } + empty := Placement{Node: "x"} + if empty.ChosenPins() != nil || empty.Bound() != nil || empty.HostPorts() != nil || len(empty.Held()) != 0 { + t.Error("an empty placement reads as something") + } + }) + + t.Run("what a change says", func(t *testing.T) { + after := g14 + after.Revision = "def" + after.Modules = []PlacedModule{{Module: "app", Ports: map[string]int{"8080": 18081}}, {Module: "new"}} + got := ChangeOf(g14, after) + want := PlacementChange{Node: "g14", Revision: "def", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}} + if !reflect.DeepEqual(got, want) { + t.Errorf("ChangeOf = %+v, want %+v", got, want) + } + pinned := g14 + pinned.Pins = nil + if got := ChangeOf(g14, pinned); !reflect.DeepEqual(got.Changed, []string{"app", "zsh"}) { + t.Errorf("a pin change changes every module on the node: %+v", got) + } + if got := ChangeOf(Placement{}, novox); !reflect.DeepEqual(got.Added, []string{"postgres"}) || got.Node != "novox" { + t.Errorf("a new node: %+v", got) + } + }) + + t.Run("the catalogue's check", func(t *testing.T) { + held := func(m string) bool { return m != "ghost" } + enrolled := func(n string) bool { return n != "stranger" } + if r := CheckPlacements(map[string]Placement{"g14": g14, "novox": novox}, held, enrolled); len(r) != 0 { + t.Fatalf("a sound placement is refused: %v", r) + } + clash := g14 + clash.Modules = append([]PlacedModule{{Module: "ghost", Ports: map[string]int{"80": 18080}}}, g14.Modules...) + clash.Modules[2] = PlacedModule{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "ace", Module: "postgres"}}} + clash.Pins = map[string]Provider{"shell": {Node: "g14", Module: "bash"}} + twice := novox + twice.Node = "stranger" + twice.Decided.Address = "10.77.0.2" + r := CheckPlacements(map[string]Placement{"g14": clash, "novox": novox, "stranger": twice}, held, enrolled) + for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080", + "bound to ace/postgres", "pin of shell names g14/bash", "stranger is not an enrolled node", + "mesh-store, held once for the mesh, is held on novox and stranger", + "g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} { + found := false + for _, line := range r { + found = found || strings.Contains(line, want) + } + if !found { + t.Errorf("no refusal says %q; got %v", want, r) + } + } + }) +} diff --git a/go/delivery/source.go b/go/delivery/source.go index 535f771..71b4de1 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -537,6 +537,11 @@ type Forge struct { Act string `json:"act,omitempty"` Words string `json:"words,omitempty"` Open []OpenPull `json:"open,omitempty"` + // Placements are, on a merged message of the catalogue repository, every placement file of the merge commit, the + // bytes exact, by node (PlacementFile; novox/hq ADR 0549 §2), and PlacementsRead says they were read, so a catalogue + // whose last placement file was removed is told from a message that read none (older writers, another repository). + Placements map[string]string `json:"placements,omitempty"` + PlacementsRead bool `json:"placements-read,omitempty"` } func (f Forge) check() error { @@ -549,6 +554,17 @@ func (f Forge) check() error { if len(f.Open) > 0 { return fmt.Errorf("delivery: only an open message names open pull requests") } + if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged { + return fmt.Errorf("delivery: only a merged message carries placement files") + } + if len(f.Placements) > 0 && !f.PlacementsRead { + return fmt.Errorf("delivery: a message with placement files must say it read them (placements-read)") + } + for node := range f.Placements { + if err := checkName("node", node); err != nil { + return fmt.Errorf("delivery: a placement file names no node: %w", err) + } + } if err := checkRepository(f.Repository); err != nil { return err } diff --git a/go/delivery/want_composed_test.go b/go/delivery/want_composed_test.go index 12512e8..72858db 100644 --- a/go/delivery/want_composed_test.go +++ b/go/delivery/want_composed_test.go @@ -8,7 +8,8 @@ import ( func hashOf(c byte) string { return "sha256:" + digestOf(c) } // 1943/AC3: a want naming its fingerprint and the digest of the composition it allows round-trips, and so does one -// carrying the answer to a failed backup; a want naming a fingerprint without a digest, a digest not of the sha256 +// carrying the answer to a failed backup, and from ADR 0547's second milestone one naming the build alone or the yes +// a person gave; a digest not of the sha256 // form, a removed want naming a digest, an unknown backup answer and a rule past the seventh are refused. func TestAWantNamesItsComposition(t *testing.T) { for _, w := range []Want{ @@ -17,6 +18,9 @@ func TestAWantNamesItsComposition(t *testing.T) { {FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupSkip, Words: "the operator: go on without a backup"}, {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleComposed, Words: "no composition of b for ace yet"}, {Removed: true}, + // ADR 0547 §2, §3 (issue 1126): from the second milestone a want names the build alone, and the yes a person gave. + {FP: fp2of('a'), Self: true}, + {FP: fp2of('a'), Self: true, Yes: hashOf('3')}, } { raw, err := EncodeWant(w) if err != nil { @@ -33,6 +37,9 @@ func TestAWantNamesItsComposition(t *testing.T) { why string }{ {Want{FP: fp2of('a')}, "names no composition"}, + {Want{FP: fp2of('a'), Self: true, Composed: hashOf('1')}, "names a composition"}, + {Want{FP: fp2of('a'), Self: true, Yes: "sha256:12"}, "a want's yes allows"}, + {Want{Removed: true, Self: true}, "removed want names no"}, {Want{FP: fp2of('a'), Composed: "sha256:12"}, "not sha256"}, {Want{Removed: true, Composed: hashOf('1')}, "removed want names no"}, {Want{FP: fp2of('a'), Composed: hashOf('1'), Backup: "later"}, `backup answer "later"`}, @@ -45,7 +52,7 @@ func TestAWantNamesItsComposition(t *testing.T) { } } if _, err := DecodeWant([]byte(`{"v":1,"fp":"` + fp2of('a') + `"}`)); err == nil || !strings.Contains(err.Error(), "names no composition") { - t.Errorf("a want of release 5, naming no composition, was read: %v", err) + t.Errorf("a want of release 5, naming no composition and not the node's own, was read: %v", err) } if RuleComposed != 7 { t.Errorf("RuleComposed = %d, want the seventh rule", RuleComposed) @@ -86,11 +93,17 @@ func TestTheNodesOwnWant(t *testing.T) { if err != nil || back != w { t.Errorf("node want came back %+v, %v", back, err) } - for _, bad := range []NodeWant{{}, {Composed: "sha256:1"}} { + for _, bad := range []NodeWant{{}, {Composed: "sha256:1"}, {Self: true, Composed: hashOf('2')}} { if _, err := EncodeNodeWant(bad); err == nil { t.Errorf("EncodeNodeWant(%+v) accepted", bad) } } + // ADR 0547 §2 (issue 1126): from the second milestone the node composes its own composition, and its want names none. + if raw, err := EncodeNodeWant(NodeWant{Self: true, Words: "the node composes its own"}); err != nil { + t.Errorf("a node want naming no composition: %v", err) + } else if back, err := DecodeNodeWant(raw); err != nil || back.Composed != "" { + t.Errorf("a node want naming no composition came back %+v, %v", back, err) + } if _, err := DecodeNodeWant([]byte(`{"v":1,"composed":"` + hashOf('2') + `","fp":"` + fp2of('a') + `"}`)); err == nil { t.Errorf("a node want naming a fingerprint was read") }