diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index d76f73b..55721c7 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -166,6 +166,15 @@ func Tools(r view.Reader, u view.UsageReader) []stdio.Tool { node, module := nodeModuleOf(args) return r.Haves(node, module) }}, + {Name: view.TrustsReadTool, + Description: "What every node trusts a mesh signature from, in one call (novox/hq ADR 0550 §2): each node's record's trusts " + + "(from node-engine_have .node), whether it names the vault's signing key as mesh-vault publishes it in its " + + "state signers and whether it still names the controller's, the nodes that do not name the vault's yet (not_yet), " + + "and every: true once all do, the gate before the controller's lease is handed away. Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{}}, + Run: func(map[string]any) (any, error) { + return r.Trusts() + }}, } } @@ -223,8 +232,9 @@ func read(state stdio.KeptState) view.StateRead { func main() { r := view.Reader{State: read(stdio.State("mesh-delivery-indexer.modules")), Builds: read(stdio.State("mesh-delivery-builder.builds")), CandidatesState: read(stdio.State("mesh-delivery-indexer.candidates")), WantsState: read(stdio.State("mesh-delivery-assigner.wants")), DifferencesState: read(stdio.State("mesh-delivery-assigner.differences")), HavesState: read(stdio.State("node-engine.have")), - ReachState: read(stdio.State("reach")), - HeardState: read(stdio.State("mesh-delivery-indexer.heard"))} + ReachState: read(stdio.State("reach")), + HeardState: read(stdio.State("mesh-delivery-indexer.heard")), + SignersState: read(stdio.State("mesh-vault.signers"))} u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")), Steps: map[string]view.StateRead{}, Pauses: map[string]view.StateRead{}, Backlog: backlogOf(socket())} for _, lang := range delivery.AskLanguages { diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go index 3cb27d2..b103e85 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -1,13 +1,17 @@ package main import ( + "crypto/ed25519" "encoding/json" "fmt" + "reflect" "strings" "sync/atomic" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/delivery" + "mesh-delivery-view/internal/view" ) @@ -438,6 +442,41 @@ func TestAssignerDifferencesAndNodeHavesToolsAnswerAsStored(t *testing.T) { t.Errorf("haves of ace (the node's own key too) = %v", got) } + // L3 s6b (ADR 0550 §2): node_trusts answers for every node in one call whether its record names the vault's key the + // vault publishes, and every is true only once all do. + vaultPub, _, _ := ed25519.GenerateKey(nil) + vaultKey, _ := json.Marshal(delivery.NewSignerKey(vaultPub, time.Now())) + vaultFP := delivery.SignerFingerprint(vaultPub) + record := func(node string, trusts ...string) string { + raw, _ := delivery.EncodeHaveNode(delivery.HaveNode{V: delivery.Version, Record: &delivery.NodeRecord{Node: node, Trusts: trusts}}) + return string(raw) + } + for _, row := range []struct { + name string + haves fake + every bool + notYet []string + }{ + {"one node trusts the controller's alone", fake{"ace.node": record("ace", "controller key sha256:aa", "vault key "+vaultFP), + "g14.node": record("g14", "controller key sha256:aa"), "ace.zsh": `{"fp":"x"}`}, false, []string{"g14"}}, + {"every node trusts the vault's key", fake{"ace.node": record("ace", "vault key "+vaultFP), + "g14.node": record("g14", "controller key sha256:aa", "vault key "+vaultFP)}, true, []string{}}, + } { + tr := view.Reader{HavesState: row.haves.reader().State, SignersState: fake{"key": string(vaultKey)}.reader().State} + out, err := run(t, tr, view.TrustsReadTool, map[string]any{}) + if err != nil { + t.Fatalf("%s: %v", row.name, err) + } + a := out.(view.TrustsAnswer) + if a.Every != row.every || !reflect.DeepEqual(a.NotYet, row.notYet) || a.VaultKey != vaultFP || len(a.Nodes) != 2 { + t.Errorf("%s: %+v", row.name, a) + } + } + if out, err := run(t, view.Reader{HavesState: fake{"ace.node": record("ace", "vault key "+vaultFP)}.reader().State, + SignersState: fake{}.reader().State}, view.TrustsReadTool, map[string]any{}); err != nil || out.(view.TrustsAnswer).Every { + t.Errorf("with no key published: %+v, %v", out, err) + } + // More haves than one call reads (4 nodes, 300 each, 1 ms a Get): a node and a module read that one key and list // nothing; the whole answer reads MaxStateReads values in parallel and says it was cut. slow := newSlow(300, time.Millisecond, func(n string, i int) string { return fmt.Sprintf("%s.m%03d", n, i) }) diff --git a/modules/mesh-delivery-view/internal/view/read.go b/modules/mesh-delivery-view/internal/view/read.go index 05ee803..6db6e79 100644 --- a/modules/mesh-delivery-view/internal/view/read.go +++ b/modules/mesh-delivery-view/internal/view/read.go @@ -44,6 +44,8 @@ type Reader struct { ReachState StateRead // the view's own reach (ADR 0556) // HeardState is the FORGE messages the indexer handled (issue 1126 note 76); unset reads none. HeardState StateRead + // SignersState is the vault's public signing halves (ADR 0550 §2); unset reads none. + SignersState StateRead } // ReachReadTool reads the view's reach: per repository, how far each module reached and the reached head (ADR 0556). @@ -649,3 +651,316 @@ func (r Reader) Differences(node, module string) (KeptAnswer, error) { func (r Reader) Haves(node, module string) (KeptAnswer, error) { return r.kept(r.HavesState, "the haves", node, module) } + +// TrustsReadTool reads what every node trusts a mesh signature from, in one call (novox/hq ADR 0550 §2; design 47's L3 +// s6b): the gate before the lease write is that every node's record names the vault's key. +const TrustsReadTool = "node_trusts" + +// NodeTrusts is what one node's record says it trusts: its lines (delivery.Trusts) and whether they name the vault's key +// the vault publishes and the controller's key. +type NodeTrusts struct { + Trusts []string `json:"trusts"` + TrustsVault bool `json:"trusts_vault"` + TrustsController bool `json:"trusts_controller"` +} + +// TrustsAnswer is node_trusts' answer: the vault's key as it publishes it, each node's trusts, the nodes whose record does +// not name the vault's key yet, and whether every node does. +type TrustsAnswer struct { + VaultKey string `json:"vault_key,omitempty"` + Nodes map[string]NodeTrusts `json:"nodes"` + NotYet []string `json:"not_yet"` + Every bool `json:"every"` + Unreadable []string `json:"unreadable,omitempty"` + Note string `json:"note,omitempty"` +} + +// Trusts reads every node's record (`.node` of the haves) and the vault's `signers` key, and answers which nodes +// trust the vault's key. Read-only. +func (r Reader) Trusts() (TrustsAnswer, error) { + if r.HavesState.Get == nil || r.HavesState.Keys == nil { + return TrustsAnswer{}, fmt.Errorf("the haves cannot be read here") + } + out := TrustsAnswer{Nodes: map[string]NodeTrusts{}, NotYet: []string{}} + if r.SignersState.Get != nil { + raw, err := r.SignersState.Get(delivery.SignersKeyKey) + if err != nil { + return TrustsAnswer{}, err + } + if len(raw) > 0 { + k, err := delivery.DecodeSignerKey(raw) + if err != nil { + return TrustsAnswer{}, err + } + out.VaultKey = k.Fingerprint + } + } + keys, err := r.HavesState.Keys() + if err != nil { + return TrustsAnswer{}, err + } + var records []string + for _, k := range keys { + if _, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode { + records = append(records, k) + } + } + values, _, err := r.HavesState.read(records) + if err != nil { + return TrustsAnswer{}, err + } + for key, raw := range values { + node := strings.TrimSuffix(key, "."+delivery.HaveNodeWord) + h, err := delivery.DecodeHaveNode(raw) + if err != nil { + out.Unreadable = append(out.Unreadable, key) + continue + } + var t NodeTrusts + if h.Record != nil { + t.Trusts = h.Record.Trusts + } + for _, line := range t.Trusts { + t.TrustsController = t.TrustsController || strings.HasPrefix(line, delivery.SignerController+" key ") + } + t.TrustsVault = out.VaultKey != "" && delivery.TrustsKey(t.Trusts, delivery.SignerVault, out.VaultKey) + out.Nodes[node] = t + if !t.TrustsVault { + out.NotYet = append(out.NotYet, node) + } + } + sort.Strings(out.NotYet) + sort.Strings(out.Unreadable) + out.Every = out.VaultKey != "" && len(out.Nodes) > 0 && len(out.NotYet) == 0 && len(out.Unreadable) == 0 + if out.VaultKey == "" { + out.Note = "the vault publishes no signing key yet (" + delivery.SignersRead + "), so no node can trust it" + } + return out, nil +} + +// Placement is the indexer's placement as stored (ADR 0549 §2): one node's key with node, every node placing a module +// with module (each node's key whole, so its pins, ports and bindings are beside the module), and every node's key with +// neither. A key that does not read as a placement is listed as unreadable, never dropped. +// +// Beside it, candidates (issue 1126, ADR 0549 §4): each open catalogue pull request's placement of a node that differs from +// the trunk's (the indexer's placement-candidates `.`), narrowed the same way, each with the node's answer from +// its `.node` have and whether that answer is for the candidate as it stands (its head and key revision). +func (r Reader) Placement(node, module string) (map[string]any, error) { + node, module = strings.TrimSpace(node), strings.TrimSpace(module) + if r.PlacementState.Keys == nil || r.PlacementState.Get == nil { + return nil, fmt.Errorf("the placement state cannot be read here") + } + candidates, err := r.PlacementCandidates(node, module) + if err != nil { + return nil, err + } + if node != "" { + key, err := delivery.PlacementKey(node) + if err != nil { + return nil, err + } + raw, err := r.PlacementState.Get(key) + if err != nil { + return nil, err + } + if len(raw) == 0 && len(candidates) == 0 { + return nil, fmt.Errorf("no placement of %s: the catalogue holds no placement file for it, or the indexer has read none yet", node) + } + out := map[string]json.RawMessage{} + if len(raw) > 0 { + out[node] = raw + } + return map[string]any{"placement": out, "candidates": candidates}, nil + } + keys, err := r.PlacementState.Keys() + if err != nil { + return nil, err + } + sort.Strings(keys) + values, err := r.PlacementState.values(keys) + if err != nil { + return nil, err + } + out := map[string]json.RawMessage{} + var unreadable []string + for i, key := range keys { + n, err := delivery.ParsePlacementKey(key) + if err != nil { + unreadable = append(unreadable, key) + continue + } + p, err := delivery.DecodePlacement(values[i]) + if err != nil { + unreadable = append(unreadable, key) + continue + } + if module != "" && !slices.Contains(p.Assigned(), module) { + continue + } + out[n] = values[i] + } + return map[string]any{"placement": out, "unreadable": unreadable, "candidates": candidates}, nil +} + +// The states of a node's answer to a placement candidate, as the view and the assigner's check read it. +const ( + // AnswerNone: the node's have holds no answer for the candidate's group (a node asleep, or not composing yet). + AnswerNone = "none" + // AnswerStale: the node answered an earlier head or revision of the candidate. + AnswerStale = "stale" +) + +// CandidateRow is one placement candidate and the node's answer to it. +type CandidateRow struct { + Group string `json:"group"` + Node string `json:"node"` + Pull delivery.Head `json:"pull"` + Revision uint64 `json:"revision"` + Placement json.RawMessage `json:"placement,omitempty"` // the candidate value as stored + Removes bool `json:"removes,omitempty"` // the head removes the node's file + Answered string `json:"answered"` // same, differs, refused, stale or none + Answer *delivery.CandidateAnswer `json:"answer,omitempty"` +} + +// PlacementCandidates is every placement candidate, narrowed by node and by a module its placement names, with each +// node's answer; beside each group's keys, a row for every other node placed at the group's head or on the trunk, whose +// answer counts by the head alone (its file is unchanged, so it has no key, ADR 0559 §5, §7). A key that does not read is +// a row naming only its key's group and node, answered none, never dropped. +func (r Reader) PlacementCandidates(node, module string) ([]CandidateRow, error) { + st := r.PlacementCandidatesState + if st.Keys == nil || st.GetEntry == nil { + return []CandidateRow{}, nil + } + keys, err := st.Keys() + if err != nil { + return nil, fmt.Errorf("reading the placement candidates: %w", err) + } + sort.Strings(keys) + // The trunk's placed nodes, each with its modules. + trunk := map[string][]string{} + if r.PlacementState.Keys != nil && r.PlacementState.Get != nil { + if tkeys, err := r.PlacementState.Keys(); err == nil { + for _, k := range tkeys { + n, err := delivery.ParsePlacementKey(k) + if err != nil { + continue + } + if raw, err := r.PlacementState.Get(k); err == nil && len(raw) > 0 { + if p, err := delivery.DecodePlacement(raw); err == nil { + trunk[n] = p.Assigned() + } + } + } + } + } + type group struct { + pull delivery.Head + keyed map[string]bool + placed map[string]bool + } + groups := map[string]*group{} + answers := map[string]map[string]delivery.CandidateAnswer{} // node, then group + answerOf := func(n, g string) (delivery.CandidateAnswer, bool) { + if _, read := answers[n]; !read { + answers[n] = r.candidateAnswers(n) + } + a, ok := answers[n][g] + return a, ok + } + rows := []CandidateRow{} + for _, key := range keys { + gname, n, err := delivery.ParsePlacementCandidateKey(key) + if err != nil { + continue + } + raw, rev, err := st.GetEntry(key) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", key, err) + } + if len(raw) == 0 { + continue + } + g := groups[gname] + if g == nil { + g = &group{keyed: map[string]bool{}, placed: map[string]bool{}} + groups[gname] = g + } + g.keyed[n] = true + row := CandidateRow{Group: gname, Node: n, Revision: rev, Placement: raw, Answered: AnswerNone} + c, err := delivery.DecodePlacementCandidate(raw) + if err == nil { + g.pull = c.Pull + if c.Placement != nil { + g.placed[n] = true + } + row.Pull, row.Removes = c.Pull, c.Placement == nil + if module != "" && (c.Placement == nil || !slices.Contains(c.Placement.Assigned(), module)) { + continue + } + } else if module != "" { + continue + } + if node != "" && n != node { + continue + } + if a, ok := answerOf(n, gname); ok && err == nil { + row.Answer = &a + row.Answered = AnswerOf(c.Pull.Head, rev, true, a) + } + rows = append(rows, row) + } + for _, gname := range slices.Sorted(maps.Keys(groups)) { + g := groups[gname] + if g.pull.Head == "" { + continue + } + others := map[string]bool{} + for n := range trunk { + others[n] = true + } + for n := range g.placed { + others[n] = true + } + for _, n := range slices.Sorted(maps.Keys(others)) { + if g.keyed[n] || (node != "" && n != node) || (module != "" && !slices.Contains(trunk[n], module)) { + continue + } + row := CandidateRow{Group: gname, Node: n, Pull: g.pull, Answered: AnswerNone} + if a, ok := answerOf(n, gname); ok { + row.Answer = &a + row.Answered = AnswerOf(g.pull.Head, 0, false, a) + } + rows = append(rows, row) + } + } + return rows, nil +} + +// AnswerOf is the state of a node's answer a to its group's candidates at head: its verdict when it answers that head +// and, for a node with a key of its own (keyed), that key's revision rev; AnswerStale otherwise. +func AnswerOf(head string, rev uint64, keyed bool, a delivery.CandidateAnswer) string { + if a.Head != head || (keyed && a.Revision != rev) { + return AnswerStale + } + return a.Verdict +} + +// candidateAnswers is a node's answers to its candidates, by group, from its `.node` have; none when it has none. +func (r Reader) candidateAnswers(node string) map[string]delivery.CandidateAnswer { + if r.HavesState.Get == nil { + return nil + } + key, err := delivery.HaveNodeKey(node) + if err != nil { + return nil + } + raw, err := r.HavesState.Get(key) + if err != nil || len(raw) == 0 { + return nil + } + h, err := delivery.DecodeHaveNode(raw) + if err != nil { + return nil + } + return h.Candidates +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 898e46f..00f8bfb 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -87,7 +87,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-config.resolved", "mesh-controller.builds", "mesh-controller.composed", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 + want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-config.resolved", "mesh-controller.builds", "mesh-controller.composed", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-vault.signers", "node-engine.have"} // build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { @@ -119,7 +119,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 } // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer // itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads. - if want := []string{"indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "builder_build", "builder_builds", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "build_usage", "view_reach", "indexer_module_history", "indexer_heard"}; !reflect.DeepEqual(m.Tools, want) { + if want := []string{"indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "builder_build", "builder_builds", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } for _, a := range m.Build.Artifacts { diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index fee8fe8..d9a4add 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -14,6 +14,7 @@ "assigner_want", "assigner_differences", "node_haves", + "node_trusts", "build_usage", "view_reach", "indexer_module_history", @@ -53,6 +54,7 @@ "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", + "mesh-vault.signers", "node-engine.have" ], "own-secrets": {