From 9c80664492afbb2b878dfce40dce91f8cf81cf5b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:58:13 +0100 Subject: [PATCH] =?UTF-8?q?Wants=20client:=20the=20keys,=20check=20and=20c?= =?UTF-8?q?odec=20of=20a=20want,=20a=20node's=20own=20want=20and=20a=20hol?= =?UTF-8?q?d=20over=20the=20seat=20mesh-wants,=20goldens=20from=20go/deliv?= =?UTF-8?q?ery's=20output=20(B7h;=20ADR=200634=20=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- placement/go/clients/meshwantsclient/go.mod | 8 + placement/go/clients/meshwantsclient/wants.go | 326 ++++++++++++++++++ .../go/clients/meshwantsclient/wants_test.go | 162 +++++++++ 3 files changed, 496 insertions(+) create mode 100644 placement/go/clients/meshwantsclient/go.mod create mode 100644 placement/go/clients/meshwantsclient/wants.go create mode 100644 placement/go/clients/meshwantsclient/wants_test.go diff --git a/placement/go/clients/meshwantsclient/go.mod b/placement/go/clients/meshwantsclient/go.mod new file mode 100644 index 0000000..0cc33be --- /dev/null +++ b/placement/go/clients/meshwantsclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-contracts/placement/go/clients/meshwantsclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-20261105000000-000000000000 +) diff --git a/placement/go/clients/meshwantsclient/wants.go b/placement/go/clients/meshwantsclient/wants.go new file mode 100644 index 0000000..9f04734 --- /dev/null +++ b/placement/go/clients/meshwantsclient/wants.go @@ -0,0 +1,326 @@ +// Package meshwantsclient is the client of the seat mesh-wants: the keys of its state wants, and the check and the codec +// of a want, a node's own want and a hold, which every module that reads or writes wants shares (the assigner that writes +// them, the node-engine, the provisioner and the artifact store that read them). The layouts are the seat's contract; this +// file writes the version and refuses what the earlier codec refused (novox/hq ADR 0528, ADR 0539, ADR 0546, ADR 0634 §4). +package meshwantsclient + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The three values of the state wants, in the seat's types. +type ( + // Want is a node's want for a module: the fingerprint it should run and the digest of the one composition it allows, + // or Removed, or Self when the node composes it itself; Newest and Rule name what holds the node back. + Want = seat.WantsByWantNodeModuleValue + // NodeWant is a node's own want: the digest of the node's own composition it may apply. + NodeWant = seat.WantsByWantNodeNodeValue + // Held is a fingerprint the assigner holds: the node whose health check failed on it, and the check's words. + Held = seat.WantsByHeldModuleFingerprintValue +) + +// The rules of ADR 0429 §6 and ADR 0539 clause 6, by number, as a want or a difference names the one that holds a node back. +const ( + RuleBuildDone = 1 // the build of F is done + RuleNotHeld = 2 // F is not held + RuleCanary = 3 // the node is the module's canary, or the canary runs F healthy + RuleStep = 4 // at most step other nodes are ahead (step 0: no limit, ADR 0528 §3) + RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary + RuleGrant = 6 // the module's credential for F exists (grants..) + RuleComposed = 7 // the module's composition for that node at F exists (ADR 0539 §6) + // RuleProvisions: every provision the module requires on that node is provided (novox/hq ADR 0570 §4). + RuleProvisions = 8 +) + +// ruleRead is the highest rule a reader takes (novox/hq ADR 0614 §2, §4): 9 and 10 are kept for the verb wait and the +// capability hold of a later release. +const ruleRead = 10 + +// The answers a want carries to a failed backup before an apply (ADR 0539 clause 16). +const ( + // BackupAgain is the answer to back the module up again before the apply. + BackupAgain = seat.WantsByWantNodeModuleValueBackupAgain + // BackupSkip is the answer to go on without a backup. + BackupSkip = seat.WantsByWantNodeModuleValueBackupSkip +) + +// NodeWord is the last part of a node's own key. +const NodeWord = "node" + +const ( + wantWord = "want" + heldWord = "held" + srcWord = "src2:" +) + +var ( + moduleNameOK = regexp.MustCompile(`^[-/_a-zA-Z0-9]+(\.[-/_a-zA-Z0-9]+)*$`) + digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +) + +func moduleKeyPart(module string) (string, error) { + if module == "" { + return "", fmt.Errorf("wants: the module name is empty") + } + if !moduleNameOK.MatchString(module) { + return "", fmt.Errorf("wants: the module name %q holds a wildcard, whitespace, '=', an empty part between dots or a character a key refuses", module) + } + return keys.EscapeName(module), nil +} + +func parseModuleKeyPart(part string) (string, error) { + if err := keys.CheckName("module", part); err != nil { + return "", err + } + module, err := keys.UnescapeName(part) + if err != nil { + return "", err + } + if back, err := moduleKeyPart(module); err != nil || back != part { + return "", fmt.Errorf("wants: the module part %q is not a module name as a key writes it", part) + } + return module, nil +} + +func checkFingerprint(fp, of string) error { + if part, ok := strings.CutPrefix(fp, srcWord); !ok || !keys.IsHex64(part) { + return fmt.Errorf("wants: %s: %q is not a fingerprint of the new form, %s and 64 lowercase hex digits", of, fp, srcWord) + } + return nil +} + +func checkDigest(digest, of string) error { + if !digestForm.MatchString(digest) { + return fmt.Errorf("wants: %s %q is not sha256:<64 lowercase hex digits>", of, digest) + } + return nil +} + +func checkRule(rule int64, of string) error { + if rule < RuleBuildDone || rule > ruleRead { + return fmt.Errorf("wants: %s names the rule %d, which is not one of 1 to %d", of, rule, ruleRead) + } + return nil +} + +// WantKey is the key, in the state wants, of a node's want for a module: `want..`. A module named `node` is +// refused, since `want..node` is the node's own key. +func WantKey(node, module string) (string, error) { + if err := keys.CheckName("node", node); err != nil { + return "", err + } + part, err := moduleKeyPart(module) + if err != nil { + return "", err + } + if module == NodeWord { + return "", fmt.Errorf(`wants: the module name "node" is refused: want.%s.node is the node's own key`, node) + } + return seat.WantsByWantNodeModuleKey(node, part), nil +} + +// WantNodeKey is the key of a node's own want: `want..node`. +func WantNodeKey(node string) (string, error) { + if err := keys.CheckName("node", node); err != nil { + return "", err + } + return seat.WantsByWantNodeNodeKey(node), nil +} + +// ParseWantNodeKey is the inverse of WantNodeKey; it refuses a module's want key and any key of another form. +func ParseWantNodeKey(key string) (node string, err error) { + node, ok := seat.ParseWantsByWantNodeNodeKey(key) + if !ok || keys.CheckName("node", node) != nil { + return "", fmt.Errorf("wants: %q is not a node's own want key, want..node", key) + } + return node, nil +} + +// ParseWantKey is the inverse of WantKey; it refuses a held key, the node's own want key and any key of another form. +func ParseWantKey(key string) (node, module string, err error) { + refused := fmt.Errorf("wants: %q is not a want key, want..", key) + node, part, ok := seat.ParseWantsByWantNodeModuleKey(key) + if !ok || part == NodeWord || keys.CheckName("node", node) != nil { + return "", "", refused + } + if module, err = parseModuleKeyPart(part); err != nil { + return "", "", refused + } + return node, module, nil +} + +// HeldKey is the key of a module's fingerprint the assigner holds: `held..`, the hex being the +// fingerprint's key part. +func HeldKey(module, fingerprint string) (string, error) { + if err := keys.CheckName("module", module); err != nil { + return "", err + } + if err := checkFingerprint(fingerprint, "a held key's fingerprint"); err != nil { + return "", err + } + return seat.WantsByHeldModuleFingerprintKey(module, strings.TrimPrefix(fingerprint, srcWord)), nil +} + +// ParseHeldKey is the inverse of HeldKey: the module and the fingerprint (`src2:` and the hex). +func ParseHeldKey(key string) (module, fingerprint string, err error) { + module, part, ok := seat.ParseWantsByHeldModuleFingerprintKey(key) + if !ok || keys.CheckName("module", module) != nil || !keys.IsHex64(part) { + return "", "", fmt.Errorf("wants: %q is not a held key, held..", key) + } + return module, srcWord + part, nil +} + +func checkWant(w Want) error { + if w.Removed { + if w.Fp != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold || w.Self || w.Yes != "" || w.Release != "" { + return fmt.Errorf("wants: a removed want names no fingerprint, composition, rule, backup answer or put") + } + return nil + } + if w.Hold && !w.Put { + return fmt.Errorf("wants: only a put holds a want") + } + if w.Put && (w.Newest != "" || w.Rule != 0) { + return fmt.Errorf("wants: a put's want is held back by no rule") + } + if err := checkFingerprint(w.Fp, "a want's fingerprint"); err != nil { + return err + } + // A node the catalogue places composes its own modules (ADR 0547 §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("wants: the want of %s says the node composes it, and names a composition", w.Fp) + case !w.Self && w.Composed == "": + return fmt.Errorf("wants: 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 w.Yes != "" { + if err := checkDigest(w.Yes, "the digest a want's yes allows"); err != nil { + return err + } + } else if w.Release != "" { + return fmt.Errorf("wants: the want of %s names the release %s and no yes it gave", w.Fp, w.Release) + } + switch w.Backup { + case "", BackupAgain, BackupSkip: + default: + return fmt.Errorf("wants: the backup answer %q is not %s or %s", w.Backup, BackupAgain, BackupSkip) + } + if w.Newest == "" && w.Rule == 0 { + return nil + } + if err := checkFingerprint(w.Newest, "a want's newest fingerprint"); err != nil { + return err + } + if w.Newest == w.Fp { + return fmt.Errorf("wants: a want at its newest fingerprint names no rule holding it back") + } + return checkRule(w.Rule, "a want held back") +} + +// CheckWant refuses a want the codec refuses. +func CheckWant(w Want) error { return checkWant(w) } + +// EncodeWant is w as JSON at the kept values' version; it refuses a fingerprint not of the new form, a removed want naming +// one, and a want that names no composition unless the node composes it itself. +func EncodeWant(w Want) ([]byte, error) { + if err := checkWant(w); err != nil { + return nil, err + } + w.V = keys.Version + return json.Marshal(w) +} + +// DecodeWant reads a want; it refuses an unknown version (keys.ErrUnknownVersion), a field the contract does not know and +// what EncodeWant refuses. +func DecodeWant(data []byte) (Want, error) { + if err := keys.CheckVersion(data); err != nil { + return Want{}, err + } + w, err := seat.DecodeWantsByWantNodeModuleValue(data) + if err != nil { + return Want{}, fmt.Errorf("wants: value does not fit its shape: %w", err) + } + if err := checkWant(w); err != nil { + return Want{}, err + } + return w, nil +} + +// SameWant says whether two wants are the same value, so the assigner writes only when they differ. +func SameWant(a, b Want) bool { return a == b } + +func checkNodeWant(w NodeWant) error { + switch { + case w.Self && w.Composed != "": + return fmt.Errorf("wants: 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") +} + +// EncodeNodeWant is w as JSON; 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 := checkNodeWant(w); err != nil { + return nil, err + } + w.V = keys.Version + return json.Marshal(w) +} + +// DecodeNodeWant reads a node's own want; it refuses an unknown version, a field a node's own want does not have (a +// fingerprint among them), and what EncodeNodeWant refuses. +func DecodeNodeWant(data []byte) (NodeWant, error) { + if err := keys.CheckVersion(data); err != nil { + return NodeWant{}, err + } + w, err := seat.DecodeWantsByWantNodeNodeValue(data) + if err != nil { + return NodeWant{}, fmt.Errorf("wants: a node's own want does not fit its shape: %w", err) + } + if err := checkNodeWant(w); err != nil { + return NodeWant{}, err + } + return w, nil +} + +func checkHeld(h Held) error { return keys.CheckName("node", h.Node) } + +// EncodeHeld is h as JSON; it refuses a node name a key refuses. +func EncodeHeld(h Held) ([]byte, error) { + if err := checkHeld(h); err != nil { + return nil, err + } + h.V = keys.Version + return json.Marshal(h) +} + +// DecodeHeld reads a hold; it refuses an unknown version and what EncodeHeld refuses. +func DecodeHeld(data []byte) (Held, error) { + if err := keys.CheckVersion(data); err != nil { + return Held{}, err + } + h, err := seat.DecodeWantsByHeldModuleFingerprintValue(data) + if err != nil { + return Held{}, fmt.Errorf("wants: value does not fit its shape: %w", err) + } + if err := checkHeld(h); err != nil { + return Held{}, err + } + return h, nil +} + +// SameHeld says whether two holds are the same value. +func SameHeld(a, b Held) bool { return a == b } diff --git a/placement/go/clients/meshwantsclient/wants_test.go b/placement/go/clients/meshwantsclient/wants_test.go new file mode 100644 index 0000000..5e2ecad --- /dev/null +++ b/placement/go/clients/meshwantsclient/wants_test.go @@ -0,0 +1,162 @@ +package meshwantsclient + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/keys" +) + +func rep(c string) string { return strings.Repeat(c, 64) } + +// TestWantsAreWhatTheEarlierCodecWrote holds the values and keys equal to what go/delivery's codec wrote for the same +// values (the golden texts are its output; the field order is the contract's now, so values are compared decoded), and the +// refusals it made. +func TestWantsAreWhatTheEarlierCodecWrote(t *testing.T) { + a, b, c, d := "src2:"+rep("a"), "src2:"+rep("b"), "sha256:"+rep("c"), "sha256:"+rep("d") + sameJSON := func(t *testing.T, got []byte, want string) { + t.Helper() + var g, w any + if err := json.Unmarshal(got, &g); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(want), &w); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(g, w) { + t.Fatalf("wrote %s, the earlier codec wrote %s", got, want) + } + } + t.Run("wants", func(t *testing.T) { + for name, row := range map[string]struct { + w Want + golden string + }{ + "composed": {Want{Fp: a, Composed: c, Words: "w"}, `{"v":1,"fp":"` + a + `","composed":"` + c + `","words":"w"}`}, + "held back": {Want{Fp: a, Self: true, Newest: b, Rule: RuleGrant, Backup: BackupAgain, Words: "held"}, `{"v":1,"fp":"` + a + `","newest":"` + b + `","rule":6,"backup":"again","words":"held","self":true}`}, + "put": {Want{Fp: a, Self: true, Put: true, Hold: true, Yes: d, Release: "r1"}, `{"v":1,"fp":"` + a + `","put":true,"hold":true,"yes":"` + d + `","release":"r1","self":true}`}, + "removed": {Want{Removed: true, Words: "gone"}, `{"v":1,"removed":true,"words":"gone"}`}, + } { + t.Run(name, func(t *testing.T) { + raw, err := EncodeWant(row.w) + if err != nil { + t.Fatal(err) + } + sameJSON(t, raw, row.golden) + back, err := DecodeWant(raw) + if err != nil || !SameWant(back, func() Want { w := row.w; w.V = keys.Version; return w }()) { + t.Fatalf("read back %+v, %v", back, err) + } + }) + } + }) + t.Run("refused", func(t *testing.T) { + for name, w := range map[string]Want{ + "old fingerprint": {Fp: "src1:abc", Composed: c}, + "no composition": {Fp: a}, + "self and composition": {Fp: a, Self: true, Composed: c}, + "hold without put": {Fp: a, Self: true, Hold: true}, + "put held back": {Fp: a, Self: true, Put: true, Newest: b, Rule: RuleStep}, + "removed names a rule": {Removed: true, Rule: 1}, + "release without yes": {Fp: a, Self: true, Release: "r"}, + "backup answer unknown": {Fp: a, Self: true, Backup: "later"}, + "newest equals fp": {Fp: a, Self: true, Newest: a, Rule: RuleStep}, + "rule 11": {Fp: a, Self: true, Newest: b, Rule: 11}, + "newest without a rule": {Fp: a, Self: true, Newest: b}, + "composition not sha256": {Fp: a, Composed: "sha256:xyz"}, + } { + if _, err := EncodeWant(w); err == nil { + t.Errorf("%s: written", name) + } + } + if _, err := EncodeWant(Want{Fp: a, Self: true, Newest: b, Rule: 10}); err != nil { + t.Errorf("rule 10 is read: %v", err) + } + if _, err := DecodeWant([]byte(`{"fp":"` + a + `","self":true}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("no version: %v", err) + } + if _, err := DecodeWant([]byte(`{"v":1,"fp":"` + a + `","self":true,"extra":1}`)); err == nil { + t.Error("an unknown field is read") + } + }) + t.Run("node want and hold", func(t *testing.T) { + raw, err := EncodeNodeWant(NodeWant{Composed: c, Words: "n"}) + if err != nil { + t.Fatal(err) + } + sameJSON(t, raw, `{"v":1,"composed":"`+c+`","words":"n"}`) + raw, err = EncodeNodeWant(NodeWant{Self: true}) + if err != nil { + t.Fatal(err) + } + sameJSON(t, raw, `{"v":1,"composed":"","self":true}`) + if back, err := DecodeNodeWant(raw); err != nil || !back.Self { + t.Fatalf("%+v %v", back, err) + } + for name, w := range map[string]NodeWant{"none": {}, "both": {Self: true, Composed: c}, "bad": {Composed: "x"}} { + if _, err := EncodeNodeWant(w); err == nil { + t.Errorf("%s: written", name) + } + } + if _, err := DecodeNodeWant([]byte(`{"v":1,"composed":"` + c + `","fp":"` + a + `"}`)); err == nil { + t.Error("a fingerprint in a node's own want is read") + } + raw, err = EncodeHeld(Held{Node: "ace", Words: "unhealthy"}) + if err != nil { + t.Fatal(err) + } + sameJSON(t, raw, `{"v":1,"node":"ace","words":"unhealthy"}`) + if h, err := DecodeHeld(raw); err != nil || !SameHeld(h, Held{V: 1, Node: "ace", Words: "unhealthy"}) { + t.Fatalf("%+v %v", h, err) + } + if _, err := EncodeHeld(Held{Node: "a.b"}); err == nil { + t.Error("a node with a dot is held") + } + }) + t.Run("keys", func(t *testing.T) { + for _, row := range []struct{ module, want string }{ + {"mesh-delivery-assigner", "want.ace.mesh-delivery-assigner"}, + {"novox.be", "want.ace.novox=2ebe"}, + } { + k, err := WantKey("ace", row.module) + if err != nil || k != row.want { + t.Fatalf("WantKey %s = %q, %v", row.module, k, err) + } + if n, m, err := ParseWantKey(k); err != nil || n != "ace" || m != row.module { + t.Fatalf("ParseWantKey %q = %q %q %v", k, n, m, err) + } + } + if k, err := HeldKey("mesh-delivery-assigner", a); err != nil || k != "held.mesh-delivery-assigner."+rep("a") { + t.Fatalf("HeldKey = %q %v", k, err) + } + if m, fp, err := ParseHeldKey("held.mesh-delivery-assigner." + rep("a")); err != nil || m != "mesh-delivery-assigner" || fp != a { + t.Fatalf("ParseHeldKey = %q %q %v", m, fp, err) + } + if k, err := WantNodeKey("ace"); err != nil || k != "want.ace.node" { + t.Fatalf("WantNodeKey = %q %v", k, err) + } + if n, err := ParseWantNodeKey("want.ace.node"); err != nil || n != "ace" { + t.Fatalf("ParseWantNodeKey = %q %v", n, err) + } + if _, err := WantKey("ace", "node"); err == nil { + t.Error("a module named node has a want key") + } + if _, err := HeldKey("novox.be", a); err == nil { + t.Error("a module with a dot has a held key") + } + for _, key := range []string{"want.ace.node", "held.m." + rep("a"), "ace.m", "want.ace", "want..m"} { + if _, _, err := ParseWantKey(key); err == nil { + t.Errorf("%q is a want key", key) + } + } + if _, err := ParseWantNodeKey("want.ace.m"); err == nil { + t.Error("a module's want is a node's own") + } + if _, _, err := ParseHeldKey("want.ace.m"); err == nil { + t.Error("a want key is a held key") + } + }) +}