From 7e99c354ac7fd1ab55fc71db45401d9e3fc0d005 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 27 Oct 2026 02:07:47 +0100 Subject: [PATCH] Give a catalogue pull request's placement a candidate state and each node an answer in its own have, so a placement change is checked by the nodes it places before it merges (issue 1126) --- go/delivery/delivery.go | 40 ++++++- go/delivery/forge_inputs_test.go | 8 ++ go/delivery/placement_candidate.go | 164 +++++++++++++++++++++++++++++ go/delivery/placement_test.go | 74 +++++++++++++ go/delivery/source.go | 8 +- 5 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 go/delivery/placement_candidate.go diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index d4f6288..1b2306f 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -402,6 +402,14 @@ type HaveNode struct { // 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"` + // Sealed is, on a node composing its own, each value its own composition carries sealed to the node's key, as + // applied (Have.Sealed for the node's own, issue 1126): a value the vault seals again after a restart moves no + // composition, so a restart applies nothing again. + Sealed []string `json:"sealed,omitempty"` + // Candidates is the node's answer to each placement candidate naming it, by group (PlacementCandidate): written by + // its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a + // candidate. + Candidates map[string]CandidateAnswer `json:"candidates,omitempty"` } // Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman @@ -497,17 +505,38 @@ func DecodeHave(data []byte) (Have, error) { return h, nil } -// EncodeHaveNode is n as JSON at Version. +func (n HaveNode) check() error { + for group, a := range n.Candidates { + if group == "" { + return fmt.Errorf("delivery: a node's candidate answer names no group") + } + if err := a.check(); err != nil { + return fmt.Errorf("delivery: the answer to %s's candidate: %w", group, err) + } + } + return nil +} + +// EncodeHaveNode is n as JSON at Version; it refuses a candidate answer CandidateAnswer's rules refuse. func EncodeHaveNode(n HaveNode) ([]byte, error) { + if err := n.check(); err != nil { + return nil, err + } n.V = Version return json.Marshal(n) } -// DecodeHaveNode reads a node's facts; it refuses an unknown version (ErrUnknownVersion). +// DecodeHaveNode reads a node's facts; it refuses an unknown version (ErrUnknownVersion) and a candidate answer that +// is not one. func DecodeHaveNode(data []byte) (HaveNode, error) { var n HaveNode - err := decode(data, &n) - return n, err + if err := decode(data, &n); err != nil { + return n, err + } + if err := n.check(); err != nil { + return HaveNode{}, err + } + return n, nil } // EncodePresence is p as JSON at Version. @@ -560,7 +589,8 @@ func SameHave(a, b Have) bool { 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) && - (a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record)) + (a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record)) && + jsonEqual(a.Sealed, b.Sealed) && jsonEqual(a.Candidates, b.Candidates) } // SamePresence says whether two presences are the same value. diff --git a/go/delivery/forge_inputs_test.go b/go/delivery/forge_inputs_test.go index 802aa77..633913b 100644 --- a/go/delivery/forge_inputs_test.go +++ b/go/delivery/forge_inputs_test.go @@ -86,6 +86,14 @@ func TestForgePullCarriesManifestsAndLocks(t *testing.T) { if _, err := EncodeForge(f); err == nil || !strings.Contains(err.Error(), `"c"`) { t.Errorf("locks outside Dirs: %v", err) } + // A pull message of the catalogue carries its head's placement files whole (placement candidates, issue 1126). + f = Forge{Kind: ForgePull, Repository: CatalogueRepository, Number: 2, Head: "h", Action: PullUpdated, + Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true} + if data, err := EncodeForge(f); err != nil { + t.Errorf("a catalogue pull with placement files: %v", err) + } else if got, err := DecodeForge(data); err != nil || !SameForge(got, withV(f)) { + t.Errorf("a catalogue pull's placement files changed on the way: %+v, %v", got, err) + } for name, f := range map[string]Forge{ "refused manifests": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Manifests: map[string]string{"a": "{}"}}, "refused locks": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Locks: map[string]map[string]string{"a": {"go.sum": "h"}}}, diff --git a/go/delivery/placement_candidate.go b/go/delivery/placement_candidate.go new file mode 100644 index 0000000..5944cac --- /dev/null +++ b/go/delivery/placement_candidate.go @@ -0,0 +1,164 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Placement candidates (issue 1126; ADR 0549 §4: a placement change passes the composition check before any node sees +// it). A catalogue pull request that changes a placement file carries every placement file at its head on its pull +// message (Forge.Placements). The indexer writes, for each node whose file at the head differs from the trunk's, the +// key `.` of its state `placement-candidates`, and deletes it when the pull request merges or closes. It is +// a state of its own, since the readers of `placement` refuse any key that is not `placement.`. +// +// The node it names composes in shadow with the candidate (its own sealed values, the boxes it already runs) and +// compares the result with what it runs; it applies nothing. Its answer rides in its own have key, `.node` +// (HaveNode.Candidates, by group), which that node alone writes; the assigner's check of the group reads every placed +// node's answer at the head and fails on any difference, refusal or silence. Event-driven both ways: the candidate key's +// write is the node's event, the `.node` change the check's. +const ( + // PlacementCandidatesState is the indexer's state of placement candidates, and PlacementCandidatesBucket its bucket. + PlacementCandidatesState = "placement-candidates" + PlacementCandidatesBucket = IndexerModule + "_" + PlacementCandidatesState + // PlacementCandidatesHistory is the revisions the bucket keeps per key. + PlacementCandidatesHistory = 5 +) + +// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate placement in one group: +// `.`, the group escaped as CandidatesKey escapes it, so a node watches `*.` and a group's keys are +// `.*`. +func PlacementCandidateKey(group, node string) (string, error) { + g, err := groupKey(group) + if err != nil { + return "", err + } + if err := checkName("node", node); err != nil { + return "", err + } + return g + "." + node, nil +} + +// ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it. +func ParsePlacementCandidateKey(key string) (group, node string, err error) { + g, node, ok := strings.Cut(key, ".") + if !ok || checkName("node", node) != nil { + return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, .", key) + } + if group, err = parseGroupKey(g); err != nil { + return "", "", fmt.Errorf("delivery: %q is not a placement candidate key: %w", key, err) + } + return group, node, nil +} + +// PlacementCandidate is the value of a candidate key: the group, the catalogue pull request and the head its placement +// files were read at, and the node's placement at that head; nil Placement says the head removes the node's file. +type PlacementCandidate struct { + V int `json:"v"` + Group string `json:"group"` + Pull Head `json:"pull"` + Placement *Placement `json:"placement,omitempty"` +} + +func (c PlacementCandidate) check() error { + if c.Group == "" { + return fmt.Errorf("delivery: a placement candidate names no group") + } + if err := checkRepository(c.Pull.Repository); err != nil { + return err + } + if c.Pull.Number <= 0 || c.Pull.Head == "" { + return fmt.Errorf("delivery: a placement candidate needs its pull request's number and head") + } + if c.Placement != nil { + return c.Placement.check() + } + return nil +} + +// EncodePlacementCandidate is c as JSON at Version; it refuses what DecodePlacementCandidate refuses. +func EncodePlacementCandidate(c PlacementCandidate) ([]byte, error) { + if err := c.check(); err != nil { + return nil, err + } + c.V = Version + if c.Placement != nil { + p := *c.Placement + p.V = Version + c.Placement = &p + } + return json.Marshal(c) +} + +// DecodePlacementCandidate reads a candidate; it refuses an unknown version, no group, a pull request without number or +// head, and a placement DecodePlacement refuses. +func DecodePlacementCandidate(data []byte) (PlacementCandidate, error) { + var c PlacementCandidate + if err := decode(data, &c); err != nil { + return PlacementCandidate{}, err + } + if err := c.check(); err != nil { + return PlacementCandidate{}, err + } + return c, nil +} + +// The verdicts of a node's answer to a candidate. +const ( + // CandidateSame: every composition the node composes with the candidate is the one it runs, and it runs no other. + CandidateSame = "same" + // CandidateDiffers: some composition differs, is composed and not run, or is run and not composed (Differs). + CandidateDiffers = "differs" + // CandidateRefused: the node cannot compose with the candidate (Refused says why: an unbound requirement, a stand-in, + // a manifest it cannot read). + CandidateRefused = "refused" +) + +// CandidateAnswer is a node's answer to its candidate of one group, in HaveNode.Candidates: the head and the key +// revision it answered, its verdict, the modules composed the same, those that differ, or why it could not compose. +// The node's own composition is named "node" (HaveNodeWord) in Same and Differs. +type CandidateAnswer struct { + Head string `json:"head"` + Revision uint64 `json:"revision"` + Verdict string `json:"verdict"` + Same []string `json:"same,omitempty"` + Differs []ModuleDiffers `json:"differs,omitempty"` + Refused string `json:"refused,omitempty"` +} + +// ModuleDiffers is one module whose composition with the candidate is not the one the node runs: the first resource +// that differs by its identity (declaration.Resource's Identity), or empty when the whole module is composed and not run +// or run and not composed, and why in words. +type ModuleDiffers struct { + Module string `json:"module"` + Resource string `json:"resource,omitempty"` + Why string `json:"why"` +} + +func (a CandidateAnswer) check() error { + if a.Head == "" { + return fmt.Errorf("delivery: a candidate answer names no head") + } + switch a.Verdict { + case CandidateSame: + if len(a.Differs) > 0 || a.Refused != "" { + return fmt.Errorf("delivery: a candidate answered same names a difference or a refusal") + } + case CandidateDiffers: + if len(a.Differs) == 0 || a.Refused != "" { + return fmt.Errorf("delivery: a candidate answered differs names no difference, or a refusal") + } + case CandidateRefused: + if a.Refused == "" || len(a.Same)+len(a.Differs) > 0 { + return fmt.Errorf("delivery: a candidate answered refused says no reason, or names modules") + } + default: + return fmt.Errorf("delivery: the candidate verdict %q is not same, differs or refused", a.Verdict) + } + for _, d := range a.Differs { + if d.Module == "" || d.Why == "" { + return fmt.Errorf("delivery: a candidate difference names no module or no why") + } + } + return nil +} diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index 2290dda..f11371d 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -179,4 +179,78 @@ func TestPlacement(t *testing.T) { } } }) + + // Issue 1126: a catalogue pull request's placement, one candidate key per node, and each node's answer in its have. + t.Run("candidates", func(t *testing.T) { + key, err := PlacementCandidateKey("feat/1.2", "g14") + if err != nil || key != "feat/1=2e2.g14" { + t.Fatalf("PlacementCandidateKey = %q, %v", key, err) + } + if g, n, err := ParsePlacementCandidateKey(key); err != nil || g != "feat/1.2" || n != "g14" { + t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", g, n, err) + } + for _, bad := range []string{"g14", ".g14", "feat.g.14", "feat.", "fe=t.g14"} { + if _, _, err := ParsePlacementCandidateKey(bad); err == nil { + t.Errorf("ParsePlacementCandidateKey(%q) took it", bad) + } + } + if PlacementCandidatesBucket != "mesh-delivery-indexer_placement-candidates" { + t.Errorf("bucket %q", PlacementCandidatesBucket) + } + pull := Head{Repository: CatalogueRepository, Number: 7, Head: "h1"} + for name, c := range map[string]PlacementCandidate{ + "placed": {Group: "feat/x", Pull: pull, Placement: &g14}, + "removed": {Group: "feat/x", Pull: pull}, + } { + raw, err := EncodePlacementCandidate(c) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + back, err := DecodePlacementCandidate(raw) + if err != nil || back.Group != c.Group || back.Pull != pull || (back.Placement == nil) != (c.Placement == nil) || + (c.Placement != nil && !SamePlacement(*back.Placement, g14)) { + t.Errorf("%s: round trip %+v, %v", name, back, err) + } + } + for name, c := range map[string]PlacementCandidate{ + "no group": {Pull: pull}, + "no head": {Group: "g", Pull: Head{Repository: CatalogueRepository, Number: 7}}, + "bad place": {Group: "g", Pull: pull, Placement: &Placement{Node: "a.b"}}, + "no repo form": {Group: "g", Pull: Head{Repository: "mesh-catalog", Number: 7, Head: "h"}}, + } { + if _, err := EncodePlacementCandidate(c); err == nil { + t.Errorf("%s: taken", name) + } + } + answers := map[string]CandidateAnswer{ + "same": {Head: "h1", Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}}, + "differs": {Head: "h1", Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}}, + "refused": {Head: "h1", Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"}, + } + n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers} + raw, err := EncodeHaveNode(n) + if err != nil { + t.Fatal(err) + } + back, err := DecodeHaveNode(raw) + if err != nil || !SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers}) { + t.Fatalf("a node's answers changed on the way: %+v, %v", back, err) + } + if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed}) { + t.Error("a node's have without its answers counts as the same") + } + for name, a := range map[string]CandidateAnswer{ + "no head": {Verdict: CandidateSame}, + "same with a diff": {Head: "h", Verdict: CandidateSame, Differs: answers["differs"].Differs}, + "differs with none": {Head: "h", Verdict: CandidateDiffers}, + "refused no reason": {Head: "h", Verdict: CandidateRefused}, + "unknown verdict": {Head: "h", Verdict: "maybe"}, + "diff with no module": {Head: "h", Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}}, + } { + if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil { + t.Errorf("%s: taken", name) + } + } + }) + } diff --git a/go/delivery/source.go b/go/delivery/source.go index 71b4de1..ebff8b3 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -540,6 +540,8 @@ type Forge struct { // 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). + // On a pull message of the catalogue (CatalogueRepository) they are every placement file at the pull request's head, + // read whole or not at all, which the indexer writes as placement candidates (PlacementCandidate). Placements map[string]string `json:"placements,omitempty"` PlacementsRead bool `json:"placements-read,omitempty"` } @@ -554,8 +556,10 @@ 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") + // A pull message of the catalogue carries its head's placement files, so its change of placement is composed and + // answered by the nodes it places before it merges (placement candidates, issue 1126; ADR 0549 §4). + if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged && (f.Kind != ForgePull || f.Repository != CatalogueRepository) { + return fmt.Errorf("delivery: only a merged message, or a pull message of the catalogue, 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)")