diff --git a/contracts/go/clients/meshwantsclient/go.mod b/contracts/go/clients/meshwantsclient/go.mod new file mode 100644 index 00000000..470f8994 --- /dev/null +++ b/contracts/go/clients/meshwantsclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) diff --git a/contracts/go/clients/meshwantsclient/wants.go b/contracts/go/clients/meshwantsclient/wants.go new file mode 100644 index 00000000..2e54a9f6 --- /dev/null +++ b/contracts/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-delivery/contracts/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/contracts/go/clients/meshwantsclient/wants_test.go b/contracts/go/clients/meshwantsclient/wants_test.go new file mode 100644 index 00000000..5e2ecadb --- /dev/null +++ b/contracts/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") + } + }) +} diff --git a/contracts/go/seats/meshwants/contract.go b/contracts/go/seats/meshwants/contract.go new file mode 100644 index 00000000..74860fa6 --- /dev/null +++ b/contracts/go/seats/meshwants/contract.go @@ -0,0 +1,337 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshwants is the contract of the seat "mesh-wants", defined whole (seats/mesh-wants/seat.json): The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539). +package meshwants + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// Seat is the seat's name. +const Seat = "mesh-wants" + +const ( + VerbUpgrade = "upgrade" + VerbBus = "bus" +) + +const ( + EventPolicyChanged = "policy-changed" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbUpgrade, VerbBus} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventPolicyChanged} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-wants.events" + FamilyMaxAgeDays = 7 +) + +// SubjectPolicyChanged is where policy-changed is published. +const SubjectPolicyChanged = "mesh.seat.mesh-wants.event.policy-changed" + +const ( + StateWants = "wants" + StateDifferences = "differences" + StateCompositions = "compositions" +) + +// WantsByWantNodeModuleKey is the key of one value of the state wants under the form "want..". +func WantsByWantNodeModuleKey(node, module string) string { return "want." + node + "." + module } + +// ParseWantsByWantNodeModuleKey reads the parts of a key of the form "want..", in the order of the form; the last result is false for a key of another form. +func ParseWantsByWantNodeModuleKey(key string) (string, string, bool) { + rest := key + if !strings.HasPrefix(rest, "want.") { + return "", "", false + } + rest = rest[5:] + v1, after1, found1 := strings.Cut(rest, ".") + if !found1 || v1 == "" { + return "", "", false + } + rest = after1 + if rest == "" { + return "", "", false + } + v3 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v1, v3, true +} + +// DecodeWantsByWantNodeModuleValue reads one value of the state wants under the form "want..", refusing a field WantsByWantNodeModuleValue does not know and any text after the value. +func DecodeWantsByWantNodeModuleValue(raw []byte) (WantsByWantNodeModuleValue, error) { + var v WantsByWantNodeModuleValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// WantsByWantNodeNodeKey is the key of one value of the state wants under the form "want..node". +func WantsByWantNodeNodeKey(node string) string { return "want." + node + ".node" } + +// ParseWantsByWantNodeNodeKey reads the parts of a key of the form "want..node", in the order of the form; the last result is false for a key of another form. +func ParseWantsByWantNodeNodeKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "want.") { + return "", false + } + rest = rest[5:] + v1, after1, found1 := strings.Cut(rest, ".node") + if !found1 || v1 == "" { + return "", false + } + rest = after1 + if rest != "" { + return "", false + } + return v1, true +} + +// DecodeWantsByWantNodeNodeValue reads one value of the state wants under the form "want..node", refusing a field WantsByWantNodeNodeValue does not know and any text after the value. +func DecodeWantsByWantNodeNodeValue(raw []byte) (WantsByWantNodeNodeValue, error) { + var v WantsByWantNodeNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// WantsByHeldModuleFingerprintKey is the key of one value of the state wants under the form "held..". +func WantsByHeldModuleFingerprintKey(module, fingerprint string) string { + return "held." + module + "." + fingerprint +} + +// ParseWantsByHeldModuleFingerprintKey reads the parts of a key of the form "held..", in the order of the form; the last result is false for a key of another form. +func ParseWantsByHeldModuleFingerprintKey(key string) (string, string, bool) { + rest := key + if !strings.HasPrefix(rest, "held.") { + return "", "", false + } + rest = rest[5:] + v1, after1, found1 := strings.Cut(rest, ".") + if !found1 || v1 == "" { + return "", "", false + } + rest = after1 + if rest == "" { + return "", "", false + } + v3 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v1, v3, true +} + +// DecodeWantsByHeldModuleFingerprintValue reads one value of the state wants under the form "held..", refusing a field WantsByHeldModuleFingerprintValue does not know and any text after the value. +func DecodeWantsByHeldModuleFingerprintValue(raw []byte) (WantsByHeldModuleFingerprintValue, error) { + var v WantsByHeldModuleFingerprintValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// DifferencesByNodeModuleKey is the key of one value of the state differences under the form ".". +func DifferencesByNodeModuleKey(node, module string) string { return node + "." + module } + +// ParseDifferencesByNodeModuleKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseDifferencesByNodeModuleKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeDifferencesByNodeModuleValue reads one value of the state differences under the form ".", refusing a field DifferencesByNodeModuleValue does not know and any text after the value. +func DecodeDifferencesByNodeModuleValue(raw []byte) (DifferencesByNodeModuleValue, error) { + var v DifferencesByNodeModuleValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// CompositionsByGroupHeadsKey is the key of one value of the state compositions under the form ".". +func CompositionsByGroupHeadsKey(group, heads string) string { return group + "." + heads } + +// ParseCompositionsByGroupHeadsKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseCompositionsByGroupHeadsKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeCompositionsByGroupHeadsValue reads one value of the state compositions under the form ".", refusing a field CompositionsByGroupHeadsValue does not know and any text after the value. +func DecodeCompositionsByGroupHeadsValue(raw []byte) (CompositionsByGroupHeadsValue, error) { + var v CompositionsByGroupHeadsValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// UpgradeInput is what upgrade takes. +type UpgradeInput struct { + // "true": each node's own composition, by its digest, and the assigner's findings + Compare string `json:"compare,omitempty"` + // one module + Module string `json:"module,omitempty"` + // with module: roll, together, record, or default to take a person's choice back + Policy string `json:"policy,omitempty"` + // with policy: why, required and kept + Why string `json:"why,omitempty"` +} + +// The values of UpgradeInput's compare. +const ( + UpgradeInputCompareTrue = "true" + UpgradeInputCompareFalse = "false" +) + +// UpgradeAnswer is what upgrade answers. +type UpgradeAnswer = map[string]any + +// BusInput is what bus takes. +type BusInput struct { +} + +// BusAnswer is what bus answers. +type BusAnswer = map[string]any + +// PolicyChangedEvent is the body of the event policy-changed. +type PolicyChangedEvent = map[string]any + +// WantsByWantNodeModuleValue is one value of the state wants under the key want... +type WantsByWantNodeModuleValue struct { + // again or skip + Backup string `json:"backup,omitempty"` + Composed string `json:"composed,omitempty"` + Fp string `json:"fp,omitempty"` + Hold bool `json:"hold,omitempty"` + Newest string `json:"newest,omitempty"` + Put bool `json:"put,omitempty"` + Release string `json:"release,omitempty"` + Removed bool `json:"removed,omitempty"` + // the rule that holds the node back, 1 to 10 + Rule int64 `json:"rule,omitempty"` + Self bool `json:"self,omitempty"` + V int64 `json:"v,omitempty"` + Words string `json:"words,omitempty"` + Yes string `json:"yes,omitempty"` +} + +// The values of WantsByWantNodeModuleValue's backup. +const ( + WantsByWantNodeModuleValueBackupAgain = "again" + WantsByWantNodeModuleValueBackupSkip = "skip" +) + +// WantsByWantNodeNodeValue is one value of the state wants under the key want..node. +type WantsByWantNodeNodeValue struct { + Composed string `json:"composed"` + Self bool `json:"self,omitempty"` + V int64 `json:"v,omitempty"` + Words string `json:"words,omitempty"` +} + +// WantsByHeldModuleFingerprintValue is one value of the state wants under the key held... +type WantsByHeldModuleFingerprintValue struct { + Node string `json:"node"` + V int64 `json:"v,omitempty"` + Words string `json:"words,omitempty"` +} + +// DifferencesByNodeModuleValue is one value of the state differences under the key .. +type DifferencesByNodeModuleValue struct { + Rule int64 `json:"rule,omitempty"` + Sent string `json:"sent,omitempty"` + SentBuild string `json:"sent-build,omitempty"` + Unexplained bool `json:"unexplained,omitempty"` + V int64 `json:"v,omitempty"` + Want string `json:"want,omitempty"` + WantRemoved bool `json:"want-removed,omitempty"` + Words string `json:"words,omitempty"` +} + +// CompositionsByGroupHeadsValue is one value of the state compositions under the key .. +type CompositionsByGroupHeadsValue struct { + Group string `json:"group"` + // 12 lowercase hex digits + Heads string `json:"heads"` + Machine string `json:"machine,omitempty"` + Machines []string `json:"machines,omitempty"` + Sdk string `json:"sdk,omitempty"` + // pass, fail or waits + State string `json:"state"` + // milliseconds + Took int64 `json:"took,omitempty"` + V int64 `json:"v,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Words string `json:"words,omitempty"` +} diff --git a/contracts/go/seats/meshwants/contract_test.go b/contracts/go/seats/meshwants/contract_test.go new file mode 100644 index 00000000..1355a9b4 --- /dev/null +++ b/contracts/go/seats/meshwants/contract_test.go @@ -0,0 +1,76 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshwants + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-wants/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "a want", subject: "", given: "{\"fp\":\"ab12\",\"rule\":3,\"v\":1}", wire: "{\"fp\":\"ab12\",\"rule\":3,\"v\":1}", + decode: func(b []byte) (any, error) { + var v WantsByWantNodeModuleValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a node's own want", subject: "", given: "{\"composed\":\"sha256:aa\",\"self\":true,\"v\":1}", wire: "{\"composed\":\"sha256:aa\",\"self\":true,\"v\":1}", + decode: func(b []byte) (any, error) { + var v WantsByWantNodeNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a held fingerprint", subject: "", given: "{\"node\":\"ace\",\"v\":1}", wire: "{\"node\":\"ace\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v WantsByHeldModuleFingerprintValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a difference", subject: "", given: "{\"unexplained\":true,\"v\":1,\"want\":\"ab12\"}", wire: "{\"unexplained\":true,\"v\":1,\"want\":\"ab12\"}", + decode: func(b []byte) (any, error) { + var v DifferencesByNodeModuleValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a composition", subject: "", given: "{\"group\":\"g1\",\"heads\":\"0123456789ab\",\"state\":\"pass\",\"v\":1}", wire: "{\"group\":\"g1\",\"heads\":\"0123456789ab\",\"state\":\"pass\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v CompositionsByGroupHeadsValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/meshwants/go.mod b/contracts/go/seats/meshwants/go.mod new file mode 100644 index 00000000..edcaa257 --- /dev/null +++ b/contracts/go/seats/meshwants/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants + +go 1.26.0 diff --git a/contracts/module.json b/contracts/module.json index 09be597e..8bd9850e 100644 --- a/contracts/module.json +++ b/contracts/module.json @@ -285,6 +285,35 @@ ], "series": "0.1", "generated-from": "." + }, + { + "name": "go-meshwants", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshwants" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshwants", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshwants" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshwantsclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshwantsclient" + ], + "series": "0.1" } ] }, diff --git a/contracts/seats/mesh-wants/fixtures/states.json b/contracts/seats/mesh-wants/fixtures/states.json new file mode 100644 index 00000000..cb38a399 --- /dev/null +++ b/contracts/seats/mesh-wants/fixtures/states.json @@ -0,0 +1,47 @@ +[ + { + "name": "a want", + "of": "state:wants/want..", + "given": { + "v": 1, + "fp": "ab12", + "rule": 3 + } + }, + { + "name": "a node's own want", + "of": "state:wants/want..node", + "given": { + "v": 1, + "composed": "sha256:aa", + "self": true + } + }, + { + "name": "a held fingerprint", + "of": "state:wants/held..", + "given": { + "v": 1, + "node": "ace" + } + }, + { + "name": "a difference", + "of": "state:differences/.", + "given": { + "v": 1, + "want": "ab12", + "unexplained": true + } + }, + { + "name": "a composition", + "of": "state:compositions/.", + "given": { + "v": 1, + "group": "g1", + "heads": "0123456789ab", + "state": "pass" + } + } +] diff --git a/contracts/seats/mesh-wants/seat.json b/contracts/seats/mesh-wants/seat.json new file mode 100644 index 00000000..a8834526 --- /dev/null +++ b/contracts/seats/mesh-wants/seat.json @@ -0,0 +1,284 @@ +{ + "format": 1, + "seat": "mesh-wants", + "scope": "mesh", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0565, ADR 0634, ADR 0635", + "description": "The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539).", + "verbs": [ + { + "name": "upgrade", + "description": "Whether each module's new builds wait for a person: its policy (roll, together or record) and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", + "input": { + "properties": { + "compare": { + "description": "\"true\": each node's own composition, by its digest, and the assigner's findings", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "module": { + "description": "one module", + "type": "string" + }, + "policy": { + "description": "with module: roll, together, record, or default to take a person's choice back", + "type": "string" + }, + "why": { + "description": "with policy: why, required and kept", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "each module's policy, or the one chosen, or the comparison" + } + }, + { + "name": "bus", + "description": "What a bus upgrade would do: the fingerprint of the bus module each node runs against the indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: the step itself is the put on the operator's warrant.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what a bus upgrade would do, read only" + } + } + ], + "events": [ + { + "name": "policy-changed", + "body": { + "type": "object" + } + } + ], + "family": { + "name": "mesh-wants.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "wants", + "template": "state", + "key-forms": [ + { + "key": "want..", + "value": { + "type": "object", + "description": "a node's want for a module", + "properties": { + "v": { + "type": "integer" + }, + "fp": { + "type": "string" + }, + "composed": { + "type": "string" + }, + "removed": { + "type": "boolean" + }, + "newest": { + "type": "string" + }, + "rule": { + "type": "integer", + "description": "the rule that holds the node back, 1 to 10" + }, + "backup": { + "type": "string", + "description": "again or skip", + "enum": [ + "again", + "skip" + ] + }, + "words": { + "type": "string" + }, + "put": { + "type": "boolean" + }, + "hold": { + "type": "boolean" + }, + "yes": { + "type": "string" + }, + "release": { + "type": "string" + }, + "self": { + "type": "boolean" + } + } + }, + "description": "a node's want for a module" + }, + { + "key": "want..node", + "value": { + "type": "object", + "description": "a node's own want", + "properties": { + "v": { + "type": "integer" + }, + "composed": { + "type": "string" + }, + "words": { + "type": "string" + }, + "self": { + "type": "boolean" + } + }, + "required": [ + "composed" + ] + }, + "description": "a node's own want, naming the digest of its own composition" + }, + { + "key": "held..", + "value": { + "type": "object", + "description": "a fingerprint the assigner holds", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "words": { + "type": "string" + } + }, + "required": [ + "node" + ] + }, + "description": "a module's fingerprint the assigner holds" + } + ], + "history": 20, + "description": "what each node should run, and the fingerprints held back" + }, + { + "name": "differences", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the difference between a want and what is sent", + "properties": { + "v": { + "type": "integer" + }, + "want": { + "type": "string" + }, + "want-removed": { + "type": "boolean" + }, + "sent": { + "type": "string" + }, + "sent-build": { + "type": "string" + }, + "rule": { + "type": "integer" + }, + "unexplained": { + "type": "boolean" + }, + "words": { + "type": "string" + } + } + }, + "description": "a module on a node" + } + ], + "history": 64, + "description": "differences between a want and what the node-engine is sent" + }, + { + "name": "compositions", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the composition verdict of a group's check at its heads", + "properties": { + "v": { + "type": "integer" + }, + "group": { + "type": "string" + }, + "heads": { + "type": "string", + "description": "12 lowercase hex digits" + }, + "state": { + "type": "string", + "description": "pass, fail or waits" + }, + "words": { + "type": "string" + }, + "sdk": { + "type": "string" + }, + "machines": { + "type": "array", + "items": { + "type": "string" + } + }, + "machine": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "took": { + "type": "integer", + "description": "milliseconds" + } + }, + "required": [ + "group", + "heads", + "state" + ] + }, + "description": "a group's check at its heads" + } + ], + "history": 5, + "description": "composition verdicts of a group's check; the checker reads them" + } + ] +} diff --git a/contracts/ts/seats/meshwants/package-lock.json b/contracts/ts/seats/meshwants/package-lock.json new file mode 100644 index 00000000..e803a33d --- /dev/null +++ b/contracts/ts/seats/meshwants/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-mesh-wants", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-mesh-wants", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshwants/package.json b/contracts/ts/seats/meshwants/package.json new file mode 100644 index 00000000..6e614224 --- /dev/null +++ b/contracts/ts/seats/meshwants/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-wants, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-mesh-wants", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshwants/src/index.ts b/contracts/ts/seats/meshwants/src/index.ts new file mode 100644 index 00000000..38dab791 --- /dev/null +++ b/contracts/ts/seats/meshwants/src/index.ts @@ -0,0 +1,211 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-wants", defined whole (seats/mesh-wants/seat.json): The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539). + +export const seat = "mesh-wants"; + +export const verbs = { + upgrade: "upgrade", + bus: "bus", +} as const; + +export const events = { + policyChanged: "policy-changed", +} as const; + +export const family = { name: "mesh-wants.events", maxAgeDays: 7 } as const; + +export const policyChangedSubject = "mesh.seat.mesh-wants.event.policy-changed"; + +export const states = { + wants: "wants", + differences: "differences", + compositions: "compositions", +} as const; + +/** The key of one value of the state wants under the form "want..". */ +export function wantsByWantNodeModuleKey(node: string, module: string): string { + return "want." + node + "." + module; +} + +/** The parts of a key of the form "want..", or undefined for a key of another form. */ +export function parseWantsByWantNodeModuleKey(_key: string): { node: string; module: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("want.")) return undefined; + _rest = _rest.slice(5); + const _at1 = _rest.indexOf("."); + if (_at1 <= 0) return undefined; + const node = _rest.slice(0, _at1); + _rest = _rest.slice(_at1 + 1); + if (_rest === "") return undefined; + const module = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, module }; +} + +/** The key of one value of the state wants under the form "want..node". */ +export function wantsByWantNodeNodeKey(node: string): string { + return "want." + node + ".node"; +} + +/** The parts of a key of the form "want..node", or undefined for a key of another form. */ +export function parseWantsByWantNodeNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("want.")) return undefined; + _rest = _rest.slice(5); + const _at1 = _rest.indexOf(".node"); + if (_at1 <= 0) return undefined; + const node = _rest.slice(0, _at1); + _rest = _rest.slice(_at1 + 5); + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state wants under the form "held..". */ +export function wantsByHeldModuleFingerprintKey(module: string, fingerprint: string): string { + return "held." + module + "." + fingerprint; +} + +/** The parts of a key of the form "held..", or undefined for a key of another form. */ +export function parseWantsByHeldModuleFingerprintKey(_key: string): { module: string; fingerprint: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("held.")) return undefined; + _rest = _rest.slice(5); + const _at1 = _rest.indexOf("."); + if (_at1 <= 0) return undefined; + const module = _rest.slice(0, _at1); + _rest = _rest.slice(_at1 + 1); + if (_rest === "") return undefined; + const fingerprint = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { module, fingerprint }; +} + +/** The key of one value of the state differences under the form ".". */ +export function differencesByNodeModuleKey(node: string, module: string): string { + return node + "." + module; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseDifferencesByNodeModuleKey(_key: string): { node: string; module: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const node = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const module = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, module }; +} + +/** The key of one value of the state compositions under the form ".". */ +export function compositionsByGroupHeadsKey(group: string, heads: string): string { + return group + "." + heads; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseCompositionsByGroupHeadsKey(_key: string): { group: string; heads: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const group = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const heads = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { group, heads }; +} + +/** is what upgrade takes. */ +export interface UpgradeInput { + /** "true": each node's own composition, by its digest, and the assigner's findings */ + "compare"?: "true" | "false"; + /** one module */ + "module"?: string; + /** with module: roll, together, record, or default to take a person's choice back */ + "policy"?: string; + /** with policy: why, required and kept */ + "why"?: string; +} + +/** is what upgrade answers. */ +export type UpgradeAnswer = Record; + +/** is what bus takes. */ +export interface BusInput { +} + +/** is what bus answers. */ +export type BusAnswer = Record; + +/** is the body of the event policy-changed. */ +export type PolicyChangedEvent = Record; + +/** is one value of the state wants under the key want... */ +export interface WantsByWantNodeModuleValue { + /** again or skip */ + "backup"?: "again" | "skip"; + "composed"?: string; + "fp"?: string; + "hold"?: boolean; + "newest"?: string; + "put"?: boolean; + "release"?: string; + "removed"?: boolean; + /** the rule that holds the node back, 1 to 10 */ + "rule"?: number; + "self"?: boolean; + "v"?: number; + "words"?: string; + "yes"?: string; +} + +/** is one value of the state wants under the key want..node. */ +export interface WantsByWantNodeNodeValue { + "composed": string; + "self"?: boolean; + "v"?: number; + "words"?: string; +} + +/** is one value of the state wants under the key held... */ +export interface WantsByHeldModuleFingerprintValue { + "node": string; + "v"?: number; + "words"?: string; +} + +/** is one value of the state differences under the key .. */ +export interface DifferencesByNodeModuleValue { + "rule"?: number; + "sent"?: string; + "sent-build"?: string; + "unexplained"?: boolean; + "v"?: number; + "want"?: string; + "want-removed"?: boolean; + "words"?: string; +} + +/** is one value of the state compositions under the key .. */ +export interface CompositionsByGroupHeadsValue { + "group": string; + /** 12 lowercase hex digits */ + "heads": string; + "machine"?: string; + "machines"?: string[]; + "sdk"?: string; + /** pass, fail or waits */ + "state": string; + /** milliseconds */ + "took"?: number; + "v"?: number; + "warnings"?: string[]; + "words"?: string; +} + diff --git a/contracts/ts/seats/meshwants/tsconfig.json b/contracts/ts/seats/meshwants/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/meshwants/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go index e5fd6b99..912af36f 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go @@ -348,12 +348,12 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) } // ADR 0636 §5, D-I3, D-I5: a seat of the mesh's own contracts is taken only from its domain's contracts module, and a move - // there is a handover in either merge order of the two trunks. `wants` (Placement) and `vault` (Identity and access) are + // there is a handover in either merge order of the two trunks. `wants` (Provisioning) and `vault` (Identity and access) are // held by mesh-contracts as an older indexer kept them. seat := func(name, domain string) string { return `{"format":1,"seat":"` + name + `","scope":"mesh","grain":"seat-list","decision":"d","domain":"` + domain + `"}` } - for name, domain := range map[string]string{"wants": "Placement", "vault": "Identity and access"} { + for name, domain := range map[string]string{"wants": "Provisioning", "vault": "Identity and access"} { var d seatdef.Definition if err := json.Unmarshal([]byte(seat(name, domain)), &d); err != nil { t.Fatal(err) @@ -365,18 +365,18 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) t.Fatal(err) } } - const dropped, placement, identity = "8888888888888888888888888888888888888888", "9999999999999999999999999999999999999999", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const dropped, provisioningDef, identity = "8888888888888888888888888888888888888888", "9999999999999999999999999999999999999999", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" seatFiles[dropped] = map[string]string{"seats/git/seat.json": git} // mesh-contracts without wants and vault - seatFiles[placement] = map[string]string{"seats/wants/seat.json": seat("wants", "Placement")} + seatFiles[provisioningDef] = map[string]string{"seats/wants/seat.json": seat("wants", "Provisioning")} seatFiles[identity] = map[string]string{"seats/vault/seat.json": seat("vault", "Identity and access")} domainModule := func(name string) string { return strings.Replace(contracts, `"mesh-contracts"`, `"`+name+`"`, 1) } // Order 1: the domain's module merges first (its own repository) and takes wants over. - merged("novox/mesh-placement", 90, placement, forgemsg.Forge{Root: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - Trees: map[string]string{"": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}, Manifests: map[string]string{"": domainModule("mesh-placement-contracts")}}) + merged("novox/mesh-provisioning", 90, provisioningDef, forgemsg.Forge{Root: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + Trees: map[string]string{"": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}, Manifests: map[string]string{"": domainModule("mesh-provisioning-contracts")}}) entry("wants.trunk", func(e index.SeatDefinitionEntry) bool { - return e.Module == "mesh-placement-contracts" && e.Commit == placement + return e.Module == "mesh-provisioning-contracts" && e.Commit == provisioningDef }) // mesh-contracts then drops both: wants stays the domain module's; vault, whose module has not merged yet, is kept. merged("novox/mesh-contracts", 91, dropped, forgemsg.Forge{Root: "dddddddddddddddddddddddddddddddddddddddd", diff --git a/modules/mesh-delivery-indexer/internal/index/control_records_test.go b/modules/mesh-delivery-indexer/internal/index/control_records_test.go index 25951d2f..3c77f41a 100644 --- a/modules/mesh-delivery-indexer/internal/index/control_records_test.go +++ b/modules/mesh-delivery-indexer/internal/index/control_records_test.go @@ -318,13 +318,13 @@ func TestTheControlRecords(t *testing.T) { // ADR 0636 §5, D-I3, D-I5: a seat of the mesh's own contracts is taken only from its domain's contracts module; a // move there is a handover in either order of the two trunks; a second definer is refused with a finding naming both. - // The seats `wants` (Placement) and `vault` (Identity and access) are held by mesh-contracts as an older indexer + // The seats `wants` (Provisioning) and `vault` (Identity and access) are held by mesh-contracts as an older indexer // kept them; their definitions are listed from the module's `seats/` directory with no index. seat := func(name, domain string) string { return `{"format":1,"seat":"` + name + `","scope":"mesh","grain":"seat-list","decision":"d","domain":"` + domain + `"}` } for _, name := range []string{"wants", "vault"} { - dom := map[string]string{"wants": "Placement", "vault": "Identity and access"}[name] + dom := map[string]string{"wants": "Provisioning", "vault": "Identity and access"}[name] var d seatdef.Definition if err := json.Unmarshal([]byte(seat(name, dom)), &d); err != nil { t.Fatal(err) @@ -334,10 +334,10 @@ func TestTheControlRecords(t *testing.T) { Commit: "old", Digest: digest, Definition: raw}) defs.vals[name+".trunk"] = data } - files["c1"] = map[string]string{"seats/wants/seat.json": seat("wants", "Placement"), "seats/vault/seat.json": seat("vault", "Identity and access"), + files["c1"] = map[string]string{"seats/wants/seat.json": seat("wants", "Provisioning"), "seats/vault/seat.json": seat("vault", "Identity and access"), "seats/lone/seat.json": seat("lone", "Core")} files["c2"] = map[string]string{"seats/lone/seat.json": seat("lone", "Core")} - files["p1"] = map[string]string{"seats/wants/seat.json": seat("wants", "Placement")} + files["p1"] = map[string]string{"seats/wants/seat.json": seat("wants", "Provisioning")} files["i1"] = map[string]string{"seats/vault/seat.json": seat("vault", "Identity and access")} domainManifest := func(module string) map[string]string { return map[string]string{meshcatalogclient.RootPath: strings.Replace(manifest, `"mesh-contracts"`, `"`+module+`"`, 1)} @@ -354,7 +354,7 @@ func TestTheControlRecords(t *testing.T) { // mesh-contracts defines wants and vault, seats of other domains: refused with a finding naming both modules, the // held entries stand, and lone (Core) is taken. mergeAt(repo, "c1", map[string]string{meshcatalogclient.RootPath: manifest}) - if v, ok := findings.vals[outside]; !ok || !strings.Contains(string(v), "mesh-placement-contracts") || !strings.Contains(string(v), "mesh-identity-and-access-contracts") || + if v, ok := findings.vals[outside]; !ok || !strings.Contains(string(v), "mesh-provisioning-contracts") || !strings.Contains(string(v), "mesh-identity-and-access-contracts") || !strings.Contains(string(v), "mesh-contracts defines it") { t.Errorf("finding %s = %s, want it naming both modules", outside, v) } @@ -366,12 +366,12 @@ func TestTheControlRecords(t *testing.T) { } // Order 1: the domain's module merges first (another repository) and takes wants over; mesh-contracts then drops it and // the entry stays the domain module's, nothing removed. - mergeAt("novox/mesh-placement", "p1", domainManifest("mesh-placement-contracts")) - if w, _ := entry("wants.trunk"); w.Module != "mesh-placement-contracts" || w.Commit != "p1" { + mergeAt("novox/mesh-provisioning", "p1", domainManifest("mesh-provisioning-contracts")) + if w, _ := entry("wants.trunk"); w.Module != "mesh-provisioning-contracts" || w.Commit != "p1" { t.Errorf("the domain's module did not take wants over: %+v", w) } mergeAt(repo, "c2", map[string]string{meshcatalogclient.RootPath: manifest}) - if w, _ := entry("wants.trunk"); w.Module != "mesh-placement-contracts" { + if w, _ := entry("wants.trunk"); w.Module != "mesh-provisioning-contracts" { t.Errorf("mesh-contracts dropping wants removed or replaced the domain module's entry: %+v", w) } if _, ok := findings.vals[outside]; ok { @@ -391,14 +391,14 @@ func TestTheControlRecords(t *testing.T) { t.Errorf("said %+v: a handover removes nothing", c) } } - // A pull request of mesh-contracts defining a Placement seat fails its check as a definition that does not parse. - files["h2"] = map[string]string{"seats/wants/seat.json": seat("wants", "Placement")} + // A pull request of mesh-contracts defining a Provisioning seat fails its check as a definition that does not parse. + files["h2"] = map[string]string{"seats/wants/seat.json": seat("wants", "Provisioning")} ix.ReadFile = func(_ context.Context, _, sha, p string) ([]byte, error) { return []byte(files[sha][p]), nil } if err := ix.ApplySeatDefinitions(ctx, forgemsg.Forge{Kind: forgemsg.ForgePull, Action: forgemsg.PullUpdated, Repository: repo, Number: 11, Head: "h2", Dirs: []string{meshcatalogclient.RootPath}, Manifests: map[string]string{meshcatalogclient.RootPath: manifest}}); err != nil { t.Fatal(err) } - if e, _ := entry(SeatDefinitionKey("wants", SourcePull, repo, 11)); !strings.Contains(e.Unparsed, "mesh-placement-contracts") || e.Definition != nil { + if e, _ := entry(SeatDefinitionKey("wants", SourcePull, repo, 11)); !strings.Contains(e.Unparsed, "mesh-provisioning-contracts") || e.Definition != nil { t.Errorf("a pull request's wants from mesh-contracts = %+v, want it refused naming the domain's module", e) } // The verb seat-definitions: no seat lists every trunk seat with its digest (never a pull request's); a seat answers it whole. diff --git a/modules/mesh-delivery-indexer/internal/index/domain.go b/modules/mesh-delivery-indexer/internal/index/domain.go index 25f73fa8..6b2eef81 100644 --- a/modules/mesh-delivery-indexer/internal/index/domain.go +++ b/modules/mesh-delivery-indexer/internal/index/domain.go @@ -19,9 +19,9 @@ import ( // fallbackContracts is the mesh's contracts module for a domain with no contracts module of its own. const fallbackContracts = "mesh-contracts" -// domainsWithContracts are the domains whose seats have a contracts module of their own (ADR 0634 §7): the seven +// domainsWithContracts are the domains whose seats have a contracts module of their own (ADR 0634 §7): the six // the milestone creates. Every other domain's seats are defined in fallbackContracts. -var domainsWithContracts = []string{"Delivery", "Placement", "Provisioning", "Identity and access", "Health and repair", +var domainsWithContracts = []string{"Delivery", "Provisioning", "Identity and access", "Health and repair", "Connectivity", "Operator and conversation"} // domainContracts is the module that defines a domain's seats: mesh--contracts, the heading lowercased with