diff --git a/contracts/go/clients/meshcatalogclient/candidate.go b/contracts/go/clients/meshcatalogclient/candidate.go new file mode 100644 index 00000000..3c0f181c --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/candidate.go @@ -0,0 +1,38 @@ +package meshcatalogclient + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" +) + +// FingerprintSet is the `fpset` of a candidate's pull requests: the first 12 hex digits of the sha256 over the lines +// `=` or `=none:` of every module with an entry in a pull's fps, sorted and joined by a newline, +// so it is the same for every order and changes with any fingerprint or reason. A manifest digest does not enter: the +// fingerprint already covers the manifest. It is the empty string when no pull request carries a fingerprint. The checker +// keys its verdict by it. +func FingerprintSet(pulls []seat.CandidatesByGroupValuePullsItem) string { + lines := map[string]bool{} + for _, p := range pulls { + for module, m := range p.Fps { + if m.Fp2 != "" { + lines[module+"="+m.Fp2] = true + } else { + lines[module+"=none:"+m.None2] = true + } + } + } + if len(lines) == 0 { + return "" + } + all := make([]string, 0, len(lines)) + for l := range lines { + all = append(all, l) + } + sort.Strings(all) + sum := sha256.Sum256([]byte(strings.Join(all, "\n"))) + return hex.EncodeToString(sum[:])[:12] +} diff --git a/contracts/go/clients/meshcatalogclient/catalog_test.go b/contracts/go/clients/meshcatalogclient/catalog_test.go new file mode 100644 index 00000000..1151fc00 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/catalog_test.go @@ -0,0 +1,151 @@ +package meshcatalogclient + +import ( + "errors" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +func hex64(c string) string { return strings.Repeat(c, 64) } + +// The digest and the candidate's fingerprint set equal what the trunk's go/delivery computed for the same input (the +// expected strings are its output), so a key or a verdict written before is read after. +func TestDigestAndFingerprintSetKeepTheirForms(t *testing.T) { + if got := ManifestDigest([]byte(`{"module":"nats"}`)); got != "19a7450f557153c318558acac91cd6972321a192f6f95c3698a0e4c57edd4359" { + t.Errorf("ManifestDigest = %s", got) + } + pull := func(fps map[string]seat.CandidatesByGroupValuePullsItemFpsValue) []seat.CandidatesByGroupValuePullsItem { + return []seat.CandidatesByGroupValuePullsItem{{Fps: fps}} + } + fps := map[string]seat.CandidatesByGroupValuePullsItemFpsValue{"nats": {Fp2: SourcePrefix2 + hex64("a")}, "zsh": {None2: "no manifest"}} + set := FingerprintSet(pull(fps)) + if set != "50a3f57cc4e2" { + t.Errorf("FingerprintSet = %s", set) + } + fps["zsh"] = seat.CandidatesByGroupValuePullsItemFpsValue{None2: "another reason"} + if FingerprintSet(pull(fps)) == set { + t.Error("a changed reason leaves the set as it was") + } + if FingerprintSet(pull(nil)) != "" { + t.Error("no fingerprint gives a set") + } +} + +// A manifest and a module are read back as written, and each rule of the old values is kept: a refusal names its reason. +func TestManifestAndModuleValuesKeepTheirRules(t *testing.T) { + if _, err := ManifestsKey("abc"); err == nil || !strings.Contains(err.Error(), `"abc"`) { + t.Errorf("ManifestsKey(abc) = %v", err) + } + if k, err := ParseManifestsKey(hex64("b")); err != nil || k != hex64("b") { + t.Errorf("ParseManifestsKey = %q, %v", k, err) + } + raw, err := EncodeManifest(seat.ManifestsByDigestValue{Module: "nats", Manifest: `{"module":"nats"}`}) + if err != nil { + t.Fatal(err) + } + if m, err := DecodeManifest(raw); err != nil || m.Module != "nats" || m.V != keys.Version { + t.Errorf("DecodeManifest = %+v, %v", m, err) + } + for _, bad := range []seat.ManifestsByDigestValue{{Module: "a.b", Manifest: "{}"}, {Module: "nats"}, {Module: "nats", Manifest: "not json"}, {Module: "nats", Manifest: "{\"a\":\"\xff\"}"}} { + if _, err := EncodeManifest(bad); err == nil { + t.Errorf("EncodeManifest(%+v) is accepted", bad) + } + } + if _, err := DecodeManifest([]byte(`{"module":"nats","manifest":"{}"}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("a manifest with no version: %v", err) + } + + good := seat.ModulesByModuleValue{Fp2: SourcePrefix2 + hex64("a"), Repository: "novox/mesh-catalog", Commit: "c", Manifest: hex64("b"), Path: RootPath} + raw, err = EncodeModule(good) + if err != nil { + t.Fatal(err) + } + if m, err := DecodeModule(raw); err != nil || m.Fp2 != good.Fp2 || m.Path != RootPath { + t.Errorf("DecodeModule = %+v, %v", m, err) + } + refused := good + refused.Verdict, refused.Problems = VerdictRefused, []string{"p"} + if _, err := EncodeModule(refused); err != nil { + t.Errorf("a refused module: %v", err) + } + for name, mutate := range map[string]func(m *seat.ModulesByModuleValue){ + "neither a fingerprint nor a reason": func(m *seat.ModulesByModuleValue) { m.Fp2 = "" }, + "both fp2 and none2": func(m *seat.ModulesByModuleValue) { m.None2 = "x" }, + "an old fingerprint of another form": func(m *seat.ModulesByModuleValue) { m.Fp = "src2:" + hex64("a") }, + "a fingerprint of the old key form": func(m *seat.ModulesByModuleValue) { m.Fp2 = "src1:" + hex64("a") }, + "inputs and no manifest": func(m *seat.ModulesByModuleValue) { + m.Manifest = "" + m.Inputs2 = &seat.ModulesByModuleValueInputs2{Tree: "t"} + }, + "a refusal with no problem": func(m *seat.ModulesByModuleValue) { m.Verdict = VerdictRefused }, + "problems with no refusal": func(m *seat.ModulesByModuleValue) { m.Problems = []string{"p"} }, + "unknown fields with no refusal": func(m *seat.ModulesByModuleValue) { m.Unknown = []string{"series"} }, + "a path outside the directory": func(m *seat.ModulesByModuleValue) { m.Paths = map[string][]string{"tools": {"../x"}} }, + "a toolchain with no recipe": func(m *seat.ModulesByModuleValue) { + m.Toolchains = map[string]seat.ToolchainUsed{"go": {Language: "go", Version: "1.26"}} + }, + "an import form with no imports": func(m *seat.ModulesByModuleValue) { + m.Inputs2 = &seat.ModulesByModuleValueInputs2{Tree: "t", ImportForm: SourcePrefix2 + hex64("a")} + }, + } { + m := good + mutate(&m) + if _, err := EncodeModule(m); err == nil { + t.Errorf("%s: EncodeModule accepts %+v", name, m) + } + } +} + +// A verdict is taken as the readers have always taken it: a missing one accepted, a refusal held with its problems, a wait +// held with the fields it waits on, and a module's own verdict field gives the same. +func TestVerdictsAreTakenAsTheReadersTookThem(t *testing.T) { + if refused, problems := TakeVerdict(nil); refused || problems != nil { + t.Errorf("no verdict: %v %v", refused, problems) + } + waits := seat.VerdictsByDigestValue{Module: "nats", State: VerdictManifestWaits, Unknown: []string{"series"}, Judge: "j"} + refused, problems := TakeVerdict(&waits) + if !refused || len(problems) != 1 || problems[0] != "it carries series, which the indexer's judge does not know yet" { + t.Errorf("a wait: %v %v", refused, problems) + } + if got := VerdictWhy(waits); got != "the indexer's judge (j) holds the manifest of nats: "+problems[0] { + t.Errorf("VerdictWhy(wait) = %q", got) + } + if as := VerdictAsRefused(waits); as.State != VerdictRefused || len(as.Unknown) != 0 || as.Problems[0] != problems[0] { + t.Errorf("VerdictAsRefused = %+v", as) + } + no := seat.VerdictsByDigestValue{Module: "nats", State: VerdictRefused, Problems: []string{"a", "b"}, Judge: "j"} + if got := VerdictWhy(no); got != "the indexer's judge (j) refused the manifest of nats: a; b" { + t.Errorf("VerdictWhy(refused) = %q", got) + } + if !SameVerdict(no, seat.VerdictsByDigestValue{Module: "nats", State: VerdictRefused, Problems: []string{"a", "b"}, Judge: "k"}) { + t.Error("another judge is another verdict") + } + fromModule := ModuleVerdict("nats", seat.ModulesByModuleValue{Verdict: VerdictRefused, Unknown: []string{"series"}, Problems: []string{"x"}}) + if fromModule == nil || fromModule.State != VerdictManifestWaits || fromModule.Unknown[0] != "series" { + t.Errorf("a refusal naming unknown fields is the wait it was written as: %+v", fromModule) + } + if ModuleVerdict("nats", seat.ModulesByModuleValue{}) != nil { + t.Error("a module with no verdict carries one") + } + for _, bad := range []seat.VerdictsByDigestValue{{Module: "nats", State: "accepted", Problems: []string{"p"}, Judge: "j"}, {Module: "nats", State: "refused", Judge: "j"}, {Module: "nats", State: "accepted"}, {Module: "nats", State: "odd", Judge: "j"}} { + if _, err := EncodeVerdict(bad); err == nil { + t.Errorf("EncodeVerdict(%+v) is accepted", bad) + } + } + raw, err := EncodeVerdict(no) + if err != nil { + t.Fatal(err) + } + if v, err := DecodeVerdict(raw); err != nil || !SameVerdict(v, no) { + t.Errorf("DecodeVerdict = %+v, %v", v, err) + } + if k, err := VerdictsKey(hex64("c")); err != nil || k != hex64("c") { + t.Errorf("VerdictsKey = %q %v", k, err) + } + if _, err := ModulesKey("a.b"); err == nil { + t.Error("ModulesKey accepts a dotted name") + } +} diff --git a/contracts/go/clients/meshcatalogclient/go.mod b/contracts/go/clients/meshcatalogclient/go.mod index 12ac8e36..3e0ec688 100644 --- a/contracts/go/clients/meshcatalogclient/go.mod +++ b/contracts/go/clients/meshcatalogclient/go.mod @@ -2,7 +2,10 @@ module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174118-f94abb95aaef +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174118-f94abb95aaef +) require ( github.com/klauspost/compress v1.20.1 // indirect diff --git a/contracts/go/clients/meshcatalogclient/manifest.go b/contracts/go/clients/meshcatalogclient/manifest.go new file mode 100644 index 00000000..38c46616 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/manifest.go @@ -0,0 +1,79 @@ +package meshcatalogclient + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "unicode/utf8" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// ManifestDigest is the hex sha256 of a manifest's bytes exactly as read at the commit: the key of a `manifests` value and +// the digest a `modules` value and a candidate's module carry (novox/hq ADR 0429 clause 16). +func ManifestDigest(raw []byte) string { + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +// ManifestsKey is the key of a `manifests` value: the manifest's digest, 64 lowercase hex digits. The indexer writes it +// once, by a create that fails when the key exists. +func ManifestsKey(digest string) (string, error) { + if !keys.IsHex64(digest) { + return "", fmt.Errorf("meshcatalogclient: %q is not a manifest digest, 64 lowercase hex digits", digest) + } + return seat.ManifestsByDigestKey(digest), nil +} + +// ParseManifestsKey is the inverse of ManifestsKey. +func ParseManifestsKey(key string) (string, error) { + digest, err := ManifestsKey(key) + if err != nil { + return "", fmt.Errorf("meshcatalogclient: %q is not a manifests key: %w", key, err) + } + return digest, nil +} + +func checkManifest(m seat.ManifestsByDigestValue) error { + if err := keys.CheckName("module", m.Module); err != nil { + return err + } + if m.Manifest == "" { + return fmt.Errorf("meshcatalogclient: the manifest of %s is empty", m.Module) + } + if !utf8.ValidString(m.Manifest) { + return fmt.Errorf("meshcatalogclient: the manifest of %s is not UTF-8 text, which a value cannot hold byte for byte", m.Module) + } + if !json.Valid([]byte(m.Manifest)) { + return fmt.Errorf("meshcatalogclient: the manifest of %s is not JSON", m.Module) + } + return nil +} + +// EncodeManifest is m as JSON at the kept values' version; it refuses a module that is not a name and a manifest that is +// empty, not UTF-8 or not JSON. +func EncodeManifest(m seat.ManifestsByDigestValue) ([]byte, error) { + if err := checkManifest(m); err != nil { + return nil, err + } + m.V = keys.Version + return json.Marshal(m) +} + +// DecodeManifest reads a `manifests` value; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeManifest +// refuses. +func DecodeManifest(data []byte) (seat.ManifestsByDigestValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.ManifestsByDigestValue{}, err + } + var m seat.ManifestsByDigestValue + if err := json.Unmarshal(data, &m); err != nil { + return seat.ManifestsByDigestValue{}, fmt.Errorf("meshcatalogclient: value does not fit its shape: %w", err) + } + if err := checkManifest(m); err != nil { + return seat.ManifestsByDigestValue{}, err + } + return m, nil +} diff --git a/contracts/go/clients/meshcatalogclient/module.go b/contracts/go/clients/meshcatalogclient/module.go new file mode 100644 index 00000000..d07b3692 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/module.go @@ -0,0 +1,133 @@ +package meshcatalogclient + +import ( + "encoding/json" + "fmt" + "path" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// RootPath is a `modules` value's path when the module's manifest is its repository's root. +const RootPath = "." + +// SourcePrefix1 opens the fingerprint of the old form a `modules` value may still carry in `fp`. +const SourcePrefix1 = "src1:" + +// ModulesKey is the key of a `modules` value: the module's name. +func ModulesKey(module string) (string, error) { + if err := keys.CheckName("module", module); err != nil { + return "", err + } + return seat.ModulesByModuleKey(module), nil +} + +// ParseModulesKey is the inverse of ModulesKey. +func ParseModulesKey(key string) (string, error) { + module, err := ModulesKey(key) + if err != nil { + return "", fmt.Errorf("meshcatalogclient: %q is not a modules key: %w", key, err) + } + return module, nil +} + +// CheckModule refuses a `modules` value that contradicts itself: both or neither of a fingerprint and the reason for none, +// of either form, an old fingerprint not of the form `src1:`, kept inputs without a manifest digest, a verdict the indexer +// does not write, problems without a refusal (or a refusal without problems), a path that is not clean inside the module's +// directory, and a fingerprint or a digest not of its form. +func CheckModule(m seat.ModulesByModuleValue) error { + switch { + case m.Fp != "" && m.None != "": + return fmt.Errorf("meshcatalogclient: a module has both a fingerprint and a reason for none") + case m.Fp == "" && m.None == "" && m.Fp2 == "" && m.None2 == "": + return fmt.Errorf("meshcatalogclient: a module has neither a fingerprint nor a reason for none, of either form") + case m.Fp != "" && !strings.HasPrefix(m.Fp, SourcePrefix1): + return fmt.Errorf("meshcatalogclient: the module's fingerprint %q does not start with %s", m.Fp, SourcePrefix1) + case m.Fp2 != "" && m.None2 != "": + return fmt.Errorf("meshcatalogclient: a module has both fp2 and none2") + } + for language, t := range m.Toolchains { + if t.Version == "" || t.Recipe == "" { + return fmt.Errorf("meshcatalogclient: the module's toolchain of %q needs its version and its recipe version", language) + } + } + if m.Inputs2 != nil && m.Manifest == "" { + return fmt.Errorf("meshcatalogclient: a module keeps new-form inputs but has no manifest digest") + } + for artifact, paths := range m.Paths { + if artifact == "" || len(paths) == 0 { + return fmt.Errorf("meshcatalogclient: the module's named paths of the artifact %q are empty or name no artifact", artifact) + } + for _, p := range paths { + if !cleanInside(p) { + return fmt.Errorf("meshcatalogclient: the artifact %q names the path %q, not a clean path inside the module's directory", artifact, p) + } + } + } + if in := m.Inputs2; in != nil { + if f := in.ImportForm; f != "" && (len(in.Imports) == 0 || !strings.HasPrefix(f, SourcePrefix2) || !keys.IsHex64(f[len(SourcePrefix2):])) { + return fmt.Errorf("meshcatalogclient: the kept import form %q is set only with imports, and is %s and 64 hex digits", f, SourcePrefix2) + } + for p, object := range in.Paths { + if !cleanInside(p) { + return fmt.Errorf("meshcatalogclient: the named path %q is not a clean path inside the module's directory", p) + } + if object == "" { + return fmt.Errorf("meshcatalogclient: the named path %q names no git object", p) + } + } + } + switch { + case m.Verdict != "" && m.Verdict != VerdictAccepted && m.Verdict != VerdictRefused: + return fmt.Errorf("meshcatalogclient: the module's verdict %q is neither %s nor %s", m.Verdict, VerdictAccepted, VerdictRefused) + case len(m.Unknown) > 0 && m.Verdict != VerdictRefused: + return fmt.Errorf("meshcatalogclient: a module's unknown fields are named only with the refusal a wait is written as") + case m.Verdict != "" && m.Manifest == "": + return fmt.Errorf("meshcatalogclient: a module carries a verdict and no manifest digest") + case (m.Verdict == VerdictRefused) != (len(m.Problems) > 0): + return fmt.Errorf("meshcatalogclient: a module's problems are named only with a refusal, and a refusal names them") + case m.Verdict == VerdictRefused && len(m.Warnings) > 0: + return fmt.Errorf("meshcatalogclient: a refused module warns: a refusal names problems") + } + if m.Fp2 != "" { + if _, err := KeyPart(m.Fp2); err != nil { + return err + } + } + if m.Manifest != "" { + if _, err := ManifestsKey(m.Manifest); err != nil { + return err + } + } + return nil +} + +func cleanInside(p string) bool { + return p != "" && p == path.Clean(p) && !strings.HasPrefix(p, "/") && p != ".." && !strings.HasPrefix(p, "../") +} + +// EncodeModule is m as JSON at the kept values' version; it refuses what CheckModule refuses. +func EncodeModule(m seat.ModulesByModuleValue) ([]byte, error) { + if err := CheckModule(m); err != nil { + return nil, err + } + m.V = keys.Version + return json.Marshal(m) +} + +// DecodeModule reads a `modules` value; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeModule refuses. +func DecodeModule(data []byte) (seat.ModulesByModuleValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.ModulesByModuleValue{}, err + } + var m seat.ModulesByModuleValue + if err := json.Unmarshal(data, &m); err != nil { + return seat.ModulesByModuleValue{}, fmt.Errorf("meshcatalogclient: value does not fit its shape: %w", err) + } + if err := CheckModule(m); err != nil { + return seat.ModulesByModuleValue{}, err + } + return m, nil +} diff --git a/contracts/go/clients/meshcatalogclient/verdict.go b/contracts/go/clients/meshcatalogclient/verdict.go new file mode 100644 index 00000000..0e398221 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/verdict.go @@ -0,0 +1,168 @@ +package meshcatalogclient + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The states of a verdict on a manifest (novox/hq ADR 0578 §1): the indexer judges every manifest it takes in once and +// every other part takes the verdict from the seat's state `verdicts`. +const ( + // VerdictAccepted is the state of a manifest the judge found no problem in. + VerdictAccepted = "accepted" + // VerdictRefused is the state of a manifest the judge found a problem in; the verdict's problems say each. + VerdictRefused = "refused" + // VerdictManifestWaits is the state of a manifest that carries a field the judge does not know: it is not refused, + // because a newer judge may know the field; the verdict's unknown names each. A reader holds the module as a refused + // one and keeps what runs. In a `modules` value it is written as a refusal naming the fields. + VerdictManifestWaits = "waits" +) + +// VerdictsKey is the key of a `verdicts` value: the digest of the manifest it judges. +func VerdictsKey(digest string) (string, error) { + if !keys.IsHex64(digest) { + return "", fmt.Errorf("meshcatalogclient: %q is not a manifest digest, 64 lowercase hex digits", digest) + } + return seat.VerdictsByDigestKey(digest), nil +} + +// VerdictRefuses says whether v refuses its manifest. +func VerdictRefuses(v seat.VerdictsByDigestValue) bool { return v.State == VerdictRefused } + +// VerdictWaits says whether v says the manifest waits for a judge that knows its fields. +func VerdictWaits(v seat.VerdictsByDigestValue) bool { return v.State == VerdictManifestWaits } + +// VerdictHeld says whether a reader holds the module as refused: the manifest is refused or waits. +func VerdictHeld(v seat.VerdictsByDigestValue) bool { return VerdictRefuses(v) || VerdictWaits(v) } + +// WaitsOn says, in words, what a waiting manifest waits on. +func WaitsOn(v seat.VerdictsByDigestValue) string { + return "it carries " + strings.Join(v.Unknown, ", ") + ", which the indexer's judge does not know yet" +} + +// TakeVerdict is how a reader takes the verdict on a manifest (ADR 0578 §3): held, with its problems, when the indexer +// wrote a refusal, or, for a manifest that waits, with the words that say which fields it waits on. A missing verdict (nil) +// is taken as accepted: every manifest the indexer holds before it has judged it was taken in under the judges that ran +// until then. A reader holds a refused module and names it with VerdictWhy, and reads the rest. +func TakeVerdict(v *seat.VerdictsByDigestValue) (refused bool, problems []string) { + if v == nil || !VerdictHeld(*v) { + return false, nil + } + if VerdictWaits(*v) { + return true, []string{WaitsOn(*v)} + } + return true, v.Problems +} + +// VerdictWhy is a refusal in a reader's words: the module, that the indexer's judge refused its manifest, and each problem. +func VerdictWhy(v seat.VerdictsByDigestValue) string { + if !VerdictHeld(v) { + return "" + } + judge := "the indexer's judge" + if v.Judge != "" { + judge += " (" + v.Judge + ")" + } + if VerdictWaits(v) { + return fmt.Sprintf("%s holds the manifest of %s: %s", judge, v.Module, WaitsOn(v)) + } + why := fmt.Sprintf("%s refused the manifest of %s:", judge, v.Module) + for _, p := range v.Problems { + why += " " + p + ";" + } + return why[:len(why)-1] +} + +// VerdictAsRefused is v as a reader that does not know the waiting state takes it: a waiting verdict is a refusal naming what +// it waits on, any other verdict is v. +func VerdictAsRefused(v seat.VerdictsByDigestValue) seat.VerdictsByDigestValue { + if !VerdictWaits(v) { + return v + } + v.State, v.Problems, v.Unknown = VerdictRefused, []string{WaitsOn(v)}, nil + return v +} + +// SameVerdict says whether v and o say the same of a manifest: the module, the state, the problems, the unknown fields and +// the warnings, whoever judged and when. The indexer writes a verdict only when it is not the same as the one it holds. +func SameVerdict(v, o seat.VerdictsByDigestValue) bool { + return v.Module == o.Module && v.State == o.State && slices.Equal(v.Problems, o.Problems) && slices.Equal(v.Unknown, o.Unknown) && slices.Equal(v.Warnings, o.Warnings) +} + +func checkVerdict(v seat.VerdictsByDigestValue) error { + if err := keys.CheckName("module", v.Module); err != nil { + return err + } + if v.State != VerdictManifestWaits && len(v.Unknown) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s names unknown fields and does not wait", v.Module) + } + switch v.State { + case VerdictAccepted: + if len(v.Problems) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s accepts its manifest and names problems", v.Module) + } + case VerdictRefused: + if len(v.Problems) == 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s refuses its manifest and names no problem", v.Module) + } + if len(v.Warnings) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s refuses its manifest and warns: a refusal names problems", v.Module) + } + case VerdictManifestWaits: + if len(v.Unknown) == 0 || len(v.Problems) > 0 || len(v.Warnings) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s waits and names no unknown field, or names problems or warnings: a wait names the fields", v.Module) + } + default: + return fmt.Errorf("meshcatalogclient: the verdict on %s is %q, none of %s, %s and %s", v.Module, v.State, VerdictAccepted, VerdictRefused, VerdictManifestWaits) + } + if v.Judge == "" { + return fmt.Errorf("meshcatalogclient: the verdict on %s names no judge", v.Module) + } + return nil +} + +// EncodeVerdict is v as JSON at the kept values' version; it refuses a verdict without a module or a judge, a state other +// than accepted, refused or waits, a refusal naming no problem and an acceptance naming one. +func EncodeVerdict(v seat.VerdictsByDigestValue) ([]byte, error) { + if err := checkVerdict(v); err != nil { + return nil, err + } + v.V = keys.Version + return json.Marshal(v) +} + +// DecodeVerdict reads a `verdicts` value; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeVerdict +// refuses. +func DecodeVerdict(data []byte) (seat.VerdictsByDigestValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.VerdictsByDigestValue{}, err + } + var v seat.VerdictsByDigestValue + if err := json.Unmarshal(data, &v); err != nil { + return seat.VerdictsByDigestValue{}, fmt.Errorf("meshcatalogclient: value does not fit its shape: %w", err) + } + if err := checkVerdict(v); err != nil { + return seat.VerdictsByDigestValue{}, err + } + return v, nil +} + +// ModuleVerdict is the verdict a `modules` value carries, or nil when it carries none (taken as accepted, TakeVerdict). A +// refusal naming unknown fields is the wait it was written as. +func ModuleVerdict(module string, m seat.ModulesByModuleValue) *seat.VerdictsByDigestValue { + if m.Verdict == "" && len(m.Warnings) == 0 { + return nil + } + if m.Verdict == "" { + return &seat.VerdictsByDigestValue{Module: module, State: VerdictAccepted, Warnings: m.Warnings} + } + if m.Verdict == VerdictRefused && len(m.Unknown) > 0 { + return &seat.VerdictsByDigestValue{Module: module, State: VerdictManifestWaits, Unknown: m.Unknown} + } + return &seat.VerdictsByDigestValue{Module: module, State: m.Verdict, Problems: m.Problems} +} diff --git a/contracts/go/seats/meshcatalog/contract.go b/contracts/go/seats/meshcatalog/contract.go index 243a0d33..728a09b0 100644 --- a/contracts/go/seats/meshcatalog/contract.go +++ b/contracts/go/seats/meshcatalog/contract.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "strings" + "time" ) // Seat is the seat's name. @@ -310,12 +311,37 @@ func DecodePackagesByPackageSeriesValue(raw []byte) (PackagesByPackageSeriesValu return v, nil } +// ToolchainUsed is a toolchain a fingerprint was computed with (ADR 0405 clause 7). +type ToolchainUsed struct { + Language string `json:"language"` + // the build module's recipe version + Recipe string `json:"recipe"` + // the installed toolchain's version + Version string `json:"version"` +} + // ModulesInput is what modules takes. type ModulesInput struct { } +// ModulesAnswerModulesItem is a type of the contract. +type ModulesAnswerModulesItem struct { + // the merge commit it was last changed at + Commit string `json:"commit"` + // its fingerprint (src2: and 64 hex digits); absent when it has none + Fp string `json:"fp,omitempty"` + Module string `json:"module"` + // why it has none + None string `json:"none,omitempty"` + // the module's directory in its repository, '.' for a root + Path string `json:"path,omitempty"` + Repository string `json:"repository"` +} + // ModulesAnswer is what modules answers. -type ModulesAnswer = map[string]any +type ModulesAnswer struct { + Modules []ModulesAnswerModulesItem `json:"modules"` +} // ModuleInput is what module takes. type ModuleInput struct { @@ -323,8 +349,26 @@ type ModuleInput struct { Module string `json:"module"` } +// ModuleAnswerModule is a type of the contract. +type ModuleAnswerModule struct { + // the merge commit it was last changed at + Commit string `json:"commit"` + // its fingerprint (src2: and 64 hex digits); absent when it has none + Fp string `json:"fp,omitempty"` + // the module's manifest as the trunk holds it + Manifest map[string]any `json:"manifest,omitempty"` + Module string `json:"module"` + // why it has none + None string `json:"none,omitempty"` + // the module's directory in its repository, '.' for a root + Path string `json:"path,omitempty"` + Repository string `json:"repository"` +} + // ModuleAnswer is what module answers. -type ModuleAnswer = map[string]any +type ModuleAnswer struct { + Module ModuleAnswerModule `json:"module"` +} // ProvidesInput is what provides takes. type ProvidesInput struct { @@ -435,9 +479,10 @@ type ModulesByModuleValue struct { Problems []string `json:"problems,omitempty"` Repository string `json:"repository"` // the source revision - Src int64 `json:"src,omitempty"` - Toolchains map[string]any `json:"toolchains,omitempty"` - Unknown []string `json:"unknown,omitempty"` + Src int64 `json:"src,omitempty"` + // by language: the toolchain FP2 was computed with + Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"` + Unknown []string `json:"unknown,omitempty"` // the layout version V int64 `json:"v,omitempty"` Verdict string `json:"verdict,omitempty"` @@ -477,12 +522,12 @@ type CandidatesByGroupValue struct { // VerdictsByDigestValue is one value of the state verdicts under the key . type VerdictsByDigestValue struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - Judge string `json:"judge,omitempty"` - Module string `json:"module"` - Problems []string `json:"problems,omitempty"` - State string `json:"state"` - Unknown []string `json:"unknown,omitempty"` + At *time.Time `json:"at,omitempty"` + Judge string `json:"judge,omitempty"` + Module string `json:"module"` + Problems []string `json:"problems,omitempty"` + State string `json:"state"` + Unknown []string `json:"unknown,omitempty"` // the layout version V int64 `json:"v,omitempty"` Warnings []string `json:"warnings,omitempty"` @@ -491,8 +536,8 @@ type VerdictsByDigestValue struct { // PredefinedByListValue is one value of the state predefined under the key list. type PredefinedByListValue struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - Commit string `json:"commit"` + At *time.Time `json:"at,omitempty"` + Commit string `json:"commit"` // the foundation's predefined list as kept List any `json:"list"` // the layout version @@ -521,7 +566,7 @@ type ConfigCandidatesByPullValueDeclaresValueValue struct { // ConfigCandidatesByPullValue is one value of the state config-candidates under the key . type ConfigCandidatesByPullValue struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` + At *time.Time `json:"at,omitempty"` // module to key to declaration Declares map[string]map[string]ConfigCandidatesByPullValueDeclaresValueValue `json:"declares"` Head string `json:"head"` @@ -539,9 +584,9 @@ type PackagesByModuleValuePackagesItem struct { // PackagesByModuleValue is one value of the state packages under the key . type PackagesByModuleValue struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - Commit string `json:"commit"` - Dir string `json:"dir,omitempty"` + At *time.Time `json:"at,omitempty"` + Commit string `json:"commit"` + Dir string `json:"dir,omitempty"` // the Go module path Module string `json:"module"` Packages []PackagesByModuleValuePackagesItem `json:"packages"` @@ -561,9 +606,9 @@ type PackagesByPackageSeriesValueNewestContentsPackagesItem struct { // PackagesByPackageSeriesValueNewestContents is a type of the contract. type PackagesByPackageSeriesValueNewestContents struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - Commit string `json:"commit"` - Dir string `json:"dir,omitempty"` + At *time.Time `json:"at,omitempty"` + Commit string `json:"commit"` + Dir string `json:"dir,omitempty"` // the Go module path Module string `json:"module"` Packages []PackagesByPackageSeriesValueNewestContentsPackagesItem `json:"packages"` @@ -592,9 +637,9 @@ type PackagesByPackageSeriesValueNumberedContentsPackagesItem struct { // PackagesByPackageSeriesValueNumberedContents is a type of the contract. type PackagesByPackageSeriesValueNumberedContents struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - Commit string `json:"commit"` - Dir string `json:"dir,omitempty"` + At *time.Time `json:"at,omitempty"` + Commit string `json:"commit"` + Dir string `json:"dir,omitempty"` // the Go module path Module string `json:"module"` Packages []PackagesByPackageSeriesValueNumberedContentsPackagesItem `json:"packages"` @@ -617,7 +662,7 @@ type PackagesByPackageSeriesValueNumbered struct { // PackagesByPackageSeriesValue is one value of the state packages under the key .. type PackagesByPackageSeriesValue struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` + At *time.Time `json:"at,omitempty"` GoModule string `json:"go-module,omitempty"` Name string `json:"name"` Newest *PackagesByPackageSeriesValueNewest `json:"newest,omitempty"` diff --git a/contracts/go/seats/meshcatalog/contract_test.go b/contracts/go/seats/meshcatalog/contract_test.go index 96bb633f..22bea547 100644 --- a/contracts/go/seats/meshcatalog/contract_test.go +++ b/contracts/go/seats/meshcatalog/contract_test.go @@ -17,13 +17,13 @@ func TestFixtures(t *testing.T) { decode func([]byte) (any, error) subjectIs *regexp.Regexp }{ - {name: "a manifest", subject: "", given: "{\"manifest\":\"{\\\"module\\\":\\\"nats\\\"}\",\"module\":\"nats\",\"v\":1}", wire: "{\"manifest\":\"{\\\"module\\\":\\\"nats\\\"}\",\"module\":\"nats\",\"v\":1}", + {name: "a manifest (xclip, read through mesh-catalog.module)", subject: "", given: "{\"manifest\":\"{\\\"build\\\":{\\\"artifacts\\\":[{\\\"binary\\\":\\\"xclip-tools\\\",\\\"from\\\":\\\"cmd/xclip-tools\\\",\\\"kind\\\":\\\"bundle\\\",\\\"language\\\":\\\"go\\\",\\\"loads\\\":[\\\"xclip-tools\\\"],\\\"name\\\":\\\"tools\\\",\\\"system\\\":\\\"arch\\\"}]},\\\"capabilities\\\":[\\\"package-manager\\\"],\\\"module\\\":\\\"xclip\\\",\\\"resources\\\":[{\\\"id\\\":\\\"package\\\",\\\"package\\\":\\\"xclip\\\",\\\"type\\\":\\\"package\\\"}],\\\"tools\\\":[\\\"xclip_copy\\\",\\\"xclip_paste\\\",\\\"xclip_targets\\\",\\\"xclip_session\\\"],\\\"version\\\":\\\"1\\\"}\",\"module\":\"xclip\",\"v\":1}", wire: "{\"manifest\":\"{\\\"build\\\":{\\\"artifacts\\\":[{\\\"binary\\\":\\\"xclip-tools\\\",\\\"from\\\":\\\"cmd/xclip-tools\\\",\\\"kind\\\":\\\"bundle\\\",\\\"language\\\":\\\"go\\\",\\\"loads\\\":[\\\"xclip-tools\\\"],\\\"name\\\":\\\"tools\\\",\\\"system\\\":\\\"arch\\\"}]},\\\"capabilities\\\":[\\\"package-manager\\\"],\\\"module\\\":\\\"xclip\\\",\\\"resources\\\":[{\\\"id\\\":\\\"package\\\",\\\"package\\\":\\\"xclip\\\",\\\"type\\\":\\\"package\\\"}],\\\"tools\\\":[\\\"xclip_copy\\\",\\\"xclip_paste\\\",\\\"xclip_targets\\\",\\\"xclip_session\\\"],\\\"version\\\":\\\"1\\\"}\",\"module\":\"xclip\",\"v\":1}", decode: func(b []byte) (any, error) { var v ManifestsByDigestValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, - {name: "a module", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"fp2\":\"src2:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository\":\"novox/mesh-catalog\",\"v\":1}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"fp2\":\"src2:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository\":\"novox/mesh-catalog\",\"v\":1}", + {name: "a module (xclip, read through mesh-catalog.modules; the inputs and toolchains no tool reads are not here)", subject: "", given: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"v\":1}", wire: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"v\":1}", decode: func(b []byte) (any, error) { var v ModulesByModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "a candidate", subject: "", given: "{\"base\":3,\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"pulls\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"modules\":[\"nats\"],\"number\":1,\"repository\":\"novox/mesh-sdk\"}],\"v\":1}", wire: "{\"base\":3,\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"pulls\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"modules\":[\"nats\"],\"number\":1,\"repository\":\"novox/mesh-sdk\"}],\"v\":1}", decode: func(b []byte) (any, error) { var v CandidatesByGroupValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, - {name: "a verdict", subject: "", given: "{\"judge\":\"trunk\",\"module\":\"nats\",\"state\":\"accepted\",\"v\":1}", wire: "{\"judge\":\"trunk\",\"module\":\"nats\",\"state\":\"accepted\",\"v\":1}", + {name: "a refused verdict (nats, read through the indexer's verdicts tool)", subject: "", given: "{\"at\":\"2026-11-04T18:51:12.614583047+01:00\",\"judge\":\"exe:661e6fb424d7ffea\",\"module\":\"nats\",\"problems\":[\"this is not a module manifest: a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: json: unknown field \\\"read-only\\\" (read without state[2].read-only)\"],\"state\":\"refused\"}", wire: "{\"at\":\"2026-11-04T18:51:12.614583047+01:00\",\"judge\":\"exe:661e6fb424d7ffea\",\"module\":\"nats\",\"problems\":[\"this is not a module manifest: a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: json: unknown field \\\"read-only\\\" (read without state[2].read-only)\"],\"state\":\"refused\"}", decode: func(b []byte) (any, error) { var v VerdictsByDigestValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "the predefined list", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"list\":{\"streams\":[]},\"v\":1}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"list\":{\"streams\":[]},\"v\":1}", decode: func(b []byte) (any, error) { var v PredefinedByListValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, @@ -41,6 +41,12 @@ func TestFixtures(t *testing.T) { err := json.Unmarshal(b, &v) return v, err }, subjectIs: nil}, + {name: "a module with its toolchain", subject: "", given: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1}", wire: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1}", + decode: func(b []byte) (any, error) { var v ModulesByModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the modules verb (captured: the first three modules)", subject: "", given: "{\"modules\":[{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:5e4a1b4ad4ea6175c05e4655ce58aeb957547149d4c9805eb27636efd50c1c87\",\"module\":\"adwaita\",\"path\":\"modules/adwaita\",\"repository\":\"novox/mesh-catalog\"},{\"commit\":\"b112adcecc41d18f54190cbd2fc864199b70c518\",\"fp\":\"src2:7dd0db8e98224f46121a5ae05c0b9c5a430eff531a5120aa40b1a59237853a3f\",\"module\":\"amqp-email-forwarder\",\"path\":\".\",\"repository\":\"jschoubben/amqp-email-forwarder\"},{\"commit\":\"dd458cb155f5f5fa3ed824a23728e444be5b156a\",\"fp\":\"src2:a98f8152f0a837dd5ba883a15efaf110ba88100193157e4bdb1a93ba55209371\",\"module\":\"artifact-store-tools\",\"path\":\"modules/artifact-store-tools\",\"repository\":\"novox/mesh-catalog\"}]}", wire: "{\"modules\":[{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:5e4a1b4ad4ea6175c05e4655ce58aeb957547149d4c9805eb27636efd50c1c87\",\"module\":\"adwaita\",\"path\":\"modules/adwaita\",\"repository\":\"novox/mesh-catalog\"},{\"commit\":\"b112adcecc41d18f54190cbd2fc864199b70c518\",\"fp\":\"src2:7dd0db8e98224f46121a5ae05c0b9c5a430eff531a5120aa40b1a59237853a3f\",\"module\":\"amqp-email-forwarder\",\"path\":\".\",\"repository\":\"jschoubben/amqp-email-forwarder\"},{\"commit\":\"dd458cb155f5f5fa3ed824a23728e444be5b156a\",\"fp\":\"src2:a98f8152f0a837dd5ba883a15efaf110ba88100193157e4bdb1a93ba55209371\",\"module\":\"artifact-store-tools\",\"path\":\"modules/artifact-store-tools\",\"repository\":\"novox/mesh-catalog\"}]}", + decode: func(b []byte) (any, error) { var v ModulesAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the module verb (captured: xclip)", subject: "", given: "{\"module\":{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"manifest\":{\"build\":{\"artifacts\":[{\"binary\":\"xclip-tools\",\"from\":\"cmd/xclip-tools\",\"kind\":\"bundle\",\"language\":\"go\",\"loads\":[\"xclip-tools\"],\"name\":\"tools\",\"system\":\"arch\"}]},\"capabilities\":[\"package-manager\"],\"module\":\"xclip\",\"resources\":[{\"id\":\"package\",\"package\":\"xclip\",\"type\":\"package\"}],\"tools\":[\"xclip_copy\",\"xclip_paste\",\"xclip_targets\",\"xclip_session\"],\"version\":\"1\"},\"module\":\"xclip\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\"}}", wire: "{\"module\":{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"manifest\":{\"build\":{\"artifacts\":[{\"binary\":\"xclip-tools\",\"from\":\"cmd/xclip-tools\",\"kind\":\"bundle\",\"language\":\"go\",\"loads\":[\"xclip-tools\"],\"name\":\"tools\",\"system\":\"arch\"}]},\"capabilities\":[\"package-manager\"],\"module\":\"xclip\",\"resources\":[{\"id\":\"package\",\"package\":\"xclip\",\"type\":\"package\"}],\"tools\":[\"xclip_copy\",\"xclip_paste\",\"xclip_targets\",\"xclip_session\"],\"version\":\"1\"},\"module\":\"xclip\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\"}}", + decode: func(b []byte) (any, error) { var v ModuleAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, } for _, c := range cases { v, err := c.decode([]byte(c.given)) diff --git a/contracts/go/seats/nodebuildgo/contract.go b/contracts/go/seats/nodebuildgo/contract.go index a734103f..3a31416a 100644 --- a/contracts/go/seats/nodebuildgo/contract.go +++ b/contracts/go/seats/nodebuildgo/contract.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "strings" + "time" ) // Seat is the seat's name. @@ -213,18 +214,18 @@ type BuiltArtifact struct { // Capacity is how many steps the node runs at once. type Capacity struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - BuildHours string `json:"build-hours,omitempty"` - BuildHoursFault string `json:"build-hours-fault,omitempty"` - ByCpu int64 `json:"by-cpu,omitempty"` - Configured int64 `json:"configured"` - ConfiguredFrom string `json:"configured-from,omitempty"` - CoresPerStep float64 `json:"cores-per-step,omitempty"` - Effective int64 `json:"effective"` - Ineligible string `json:"ineligible,omitempty"` - OutsideHours bool `json:"outside-hours,omitempty"` - QuotaCores float64 `json:"quota-cores,omitempty"` - QuotaFrom string `json:"quota-from,omitempty"` + At *time.Time `json:"at,omitempty"` + BuildHours string `json:"build-hours,omitempty"` + BuildHoursFault string `json:"build-hours-fault,omitempty"` + ByCpu int64 `json:"by-cpu,omitempty"` + Configured int64 `json:"configured"` + ConfiguredFrom string `json:"configured-from,omitempty"` + CoresPerStep float64 `json:"cores-per-step,omitempty"` + Effective int64 `json:"effective"` + Ineligible string `json:"ineligible,omitempty"` + OutsideHours bool `json:"outside-hours,omitempty"` + QuotaCores float64 `json:"quota-cores,omitempty"` + QuotaFrom string `json:"quota-from,omitempty"` // the layout version V int64 `json:"v,omitempty"` Why string `json:"why"` @@ -318,9 +319,9 @@ type LevelAnswer struct { // Pause is whether the node takes no new ask. type Pause struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - By string `json:"by,omitempty"` - Paused bool `json:"paused"` + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Paused bool `json:"paused"` // the layout version V int64 `json:"v,omitempty"` } @@ -328,17 +329,17 @@ type Pause struct { // Step is one step a build module runs on a node. type Step struct { // when (RFC 3339 time) - Ended string `json:"ended,omitempty"` - ID string `json:"id"` - Node string `json:"node"` - Owner string `json:"owner,omitempty"` - Phase string `json:"phase,omitempty"` + Ended *time.Time `json:"ended,omitempty"` + ID string `json:"id"` + Node string `json:"node"` + Owner string `json:"owner,omitempty"` + Phase string `json:"phase,omitempty"` // when (RFC 3339 time) - PhaseAt string `json:"phase-at,omitempty"` + PhaseAt *time.Time `json:"phase-at,omitempty"` Reason string `json:"reason,omitempty"` Result *StepResult `json:"result,omitempty"` // when (RFC 3339 time) - Started string `json:"started"` + Started time.Time `json:"started"` // running, passed, failed or refused State string `json:"state"` Tool string `json:"tool"` @@ -383,7 +384,7 @@ type StepAskCompose struct { type StepAsk struct { Asker string `json:"asker"` // when (RFC 3339 time) - At string `json:"at,omitempty"` + At *time.Time `json:"at,omitempty"` Build *StepAskBuild `json:"build,omitempty"` Check *StepAskCheck `json:"check,omitempty"` Compose *StepAskCompose `json:"compose,omitempty"` @@ -422,7 +423,7 @@ type StepResult struct { // Toolchain is the toolchain a node reports. type Toolchain struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` + At *time.Time `json:"at,omitempty"` // go or typescript Language string `json:"language"` Recipe string `json:"recipe"` diff --git a/contracts/go/seats/nodebuildimage/contract.go b/contracts/go/seats/nodebuildimage/contract.go index db640ee3..e5445d8a 100644 --- a/contracts/go/seats/nodebuildimage/contract.go +++ b/contracts/go/seats/nodebuildimage/contract.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "strings" + "time" ) // Seat is the seat's name. @@ -213,18 +214,18 @@ type BuiltArtifact struct { // Capacity is how many steps the node runs at once. type Capacity struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - BuildHours string `json:"build-hours,omitempty"` - BuildHoursFault string `json:"build-hours-fault,omitempty"` - ByCpu int64 `json:"by-cpu,omitempty"` - Configured int64 `json:"configured"` - ConfiguredFrom string `json:"configured-from,omitempty"` - CoresPerStep float64 `json:"cores-per-step,omitempty"` - Effective int64 `json:"effective"` - Ineligible string `json:"ineligible,omitempty"` - OutsideHours bool `json:"outside-hours,omitempty"` - QuotaCores float64 `json:"quota-cores,omitempty"` - QuotaFrom string `json:"quota-from,omitempty"` + At *time.Time `json:"at,omitempty"` + BuildHours string `json:"build-hours,omitempty"` + BuildHoursFault string `json:"build-hours-fault,omitempty"` + ByCpu int64 `json:"by-cpu,omitempty"` + Configured int64 `json:"configured"` + ConfiguredFrom string `json:"configured-from,omitempty"` + CoresPerStep float64 `json:"cores-per-step,omitempty"` + Effective int64 `json:"effective"` + Ineligible string `json:"ineligible,omitempty"` + OutsideHours bool `json:"outside-hours,omitempty"` + QuotaCores float64 `json:"quota-cores,omitempty"` + QuotaFrom string `json:"quota-from,omitempty"` // the layout version V int64 `json:"v,omitempty"` Why string `json:"why"` @@ -318,9 +319,9 @@ type LevelAnswer struct { // Pause is whether the node takes no new ask. type Pause struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - By string `json:"by,omitempty"` - Paused bool `json:"paused"` + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Paused bool `json:"paused"` // the layout version V int64 `json:"v,omitempty"` } @@ -328,17 +329,17 @@ type Pause struct { // Step is one step a build module runs on a node. type Step struct { // when (RFC 3339 time) - Ended string `json:"ended,omitempty"` - ID string `json:"id"` - Node string `json:"node"` - Owner string `json:"owner,omitempty"` - Phase string `json:"phase,omitempty"` + Ended *time.Time `json:"ended,omitempty"` + ID string `json:"id"` + Node string `json:"node"` + Owner string `json:"owner,omitempty"` + Phase string `json:"phase,omitempty"` // when (RFC 3339 time) - PhaseAt string `json:"phase-at,omitempty"` + PhaseAt *time.Time `json:"phase-at,omitempty"` Reason string `json:"reason,omitempty"` Result *StepResult `json:"result,omitempty"` // when (RFC 3339 time) - Started string `json:"started"` + Started time.Time `json:"started"` // running, passed, failed or refused State string `json:"state"` Tool string `json:"tool"` @@ -383,7 +384,7 @@ type StepAskCompose struct { type StepAsk struct { Asker string `json:"asker"` // when (RFC 3339 time) - At string `json:"at,omitempty"` + At *time.Time `json:"at,omitempty"` Build *StepAskBuild `json:"build,omitempty"` Check *StepAskCheck `json:"check,omitempty"` Compose *StepAskCompose `json:"compose,omitempty"` @@ -422,7 +423,7 @@ type StepResult struct { // Toolchain is the toolchain a node reports. type Toolchain struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` + At *time.Time `json:"at,omitempty"` // go or typescript Language string `json:"language"` Recipe string `json:"recipe"` diff --git a/contracts/go/seats/nodebuildnode/contract.go b/contracts/go/seats/nodebuildnode/contract.go index 9b0c7ff8..9f2abc00 100644 --- a/contracts/go/seats/nodebuildnode/contract.go +++ b/contracts/go/seats/nodebuildnode/contract.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "strings" + "time" ) // Seat is the seat's name. @@ -213,18 +214,18 @@ type BuiltArtifact struct { // Capacity is how many steps the node runs at once. type Capacity struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - BuildHours string `json:"build-hours,omitempty"` - BuildHoursFault string `json:"build-hours-fault,omitempty"` - ByCpu int64 `json:"by-cpu,omitempty"` - Configured int64 `json:"configured"` - ConfiguredFrom string `json:"configured-from,omitempty"` - CoresPerStep float64 `json:"cores-per-step,omitempty"` - Effective int64 `json:"effective"` - Ineligible string `json:"ineligible,omitempty"` - OutsideHours bool `json:"outside-hours,omitempty"` - QuotaCores float64 `json:"quota-cores,omitempty"` - QuotaFrom string `json:"quota-from,omitempty"` + At *time.Time `json:"at,omitempty"` + BuildHours string `json:"build-hours,omitempty"` + BuildHoursFault string `json:"build-hours-fault,omitempty"` + ByCpu int64 `json:"by-cpu,omitempty"` + Configured int64 `json:"configured"` + ConfiguredFrom string `json:"configured-from,omitempty"` + CoresPerStep float64 `json:"cores-per-step,omitempty"` + Effective int64 `json:"effective"` + Ineligible string `json:"ineligible,omitempty"` + OutsideHours bool `json:"outside-hours,omitempty"` + QuotaCores float64 `json:"quota-cores,omitempty"` + QuotaFrom string `json:"quota-from,omitempty"` // the layout version V int64 `json:"v,omitempty"` Why string `json:"why"` @@ -318,9 +319,9 @@ type LevelAnswer struct { // Pause is whether the node takes no new ask. type Pause struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` - By string `json:"by,omitempty"` - Paused bool `json:"paused"` + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Paused bool `json:"paused"` // the layout version V int64 `json:"v,omitempty"` } @@ -328,17 +329,17 @@ type Pause struct { // Step is one step a build module runs on a node. type Step struct { // when (RFC 3339 time) - Ended string `json:"ended,omitempty"` - ID string `json:"id"` - Node string `json:"node"` - Owner string `json:"owner,omitempty"` - Phase string `json:"phase,omitempty"` + Ended *time.Time `json:"ended,omitempty"` + ID string `json:"id"` + Node string `json:"node"` + Owner string `json:"owner,omitempty"` + Phase string `json:"phase,omitempty"` // when (RFC 3339 time) - PhaseAt string `json:"phase-at,omitempty"` + PhaseAt *time.Time `json:"phase-at,omitempty"` Reason string `json:"reason,omitempty"` Result *StepResult `json:"result,omitempty"` // when (RFC 3339 time) - Started string `json:"started"` + Started time.Time `json:"started"` // running, passed, failed or refused State string `json:"state"` Tool string `json:"tool"` @@ -383,7 +384,7 @@ type StepAskCompose struct { type StepAsk struct { Asker string `json:"asker"` // when (RFC 3339 time) - At string `json:"at,omitempty"` + At *time.Time `json:"at,omitempty"` Build *StepAskBuild `json:"build,omitempty"` Check *StepAskCheck `json:"check,omitempty"` Compose *StepAskCompose `json:"compose,omitempty"` @@ -422,7 +423,7 @@ type StepResult struct { // Toolchain is the toolchain a node reports. type Toolchain struct { // when (RFC 3339 time) - At string `json:"at,omitempty"` + At *time.Time `json:"at,omitempty"` // go or typescript Language string `json:"language"` Recipe string `json:"recipe"` diff --git a/contracts/seats/mesh-catalog/fixtures/states.json b/contracts/seats/mesh-catalog/fixtures/states.json index 9d902dc9..0ad6086b 100644 --- a/contracts/seats/mesh-catalog/fixtures/states.json +++ b/contracts/seats/mesh-catalog/fixtures/states.json @@ -1,21 +1,22 @@ [ { - "name": "a manifest", + "name": "a manifest (xclip, read through mesh-catalog.module)", "of": "state:manifests/", "given": { "v": 1, - "module": "nats", - "manifest": "{\"module\":\"nats\"}" + "module": "xclip", + "manifest": "{\"build\":{\"artifacts\":[{\"binary\":\"xclip-tools\",\"from\":\"cmd/xclip-tools\",\"kind\":\"bundle\",\"language\":\"go\",\"loads\":[\"xclip-tools\"],\"name\":\"tools\",\"system\":\"arch\"}]},\"capabilities\":[\"package-manager\"],\"module\":\"xclip\",\"resources\":[{\"id\":\"package\",\"package\":\"xclip\",\"type\":\"package\"}],\"tools\":[\"xclip_copy\",\"xclip_paste\",\"xclip_targets\",\"xclip_session\"],\"version\":\"1\"}" } }, { - "name": "a module", + "name": "a module (xclip, read through mesh-catalog.modules; the inputs and toolchains no tool reads are not here)", "of": "state:modules/", "given": { "v": 1, "repository": "novox/mesh-catalog", - "commit": "cccccccccccccccccccccccccccccccccccccccc", - "fp2": "src2:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "commit": "f469eb387a3015569d9b16c66ed418e9dd098f64", + "path": "modules/xclip", + "fp2": "src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56" } }, { @@ -39,13 +40,16 @@ } }, { - "name": "a verdict", + "name": "a refused verdict (nats, read through the indexer's verdicts tool)", "of": "state:verdicts/", "given": { - "v": 1, "module": "nats", - "state": "accepted", - "judge": "trunk" + "state": "refused", + "problems": [ + "this is not a module manifest: a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: json: unknown field \"read-only\" (read without state[2].read-only)" + ], + "judge": "exe:661e6fb424d7ffea", + "at": "2026-11-04T18:51:12.614583047+01:00" } }, { @@ -101,5 +105,99 @@ "name": "go-keys", "series": "0.1" } + }, + { + "name": "a module with its toolchain", + "of": "state:modules/", + "given": { + "v": 1, + "repository": "novox/mesh-catalog", + "commit": "f469eb387a3015569d9b16c66ed418e9dd098f64", + "fp2": "src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56", + "toolchains": { + "go": { + "language": "go", + "version": "1.26.0", + "recipe": "1" + } + } + } + }, + { + "name": "the modules verb (captured: the first three modules)", + "of": "verb:modules.answer", + "given": { + "modules": [ + { + "commit": "f469eb387a3015569d9b16c66ed418e9dd098f64", + "fp": "src2:5e4a1b4ad4ea6175c05e4655ce58aeb957547149d4c9805eb27636efd50c1c87", + "module": "adwaita", + "path": "modules/adwaita", + "repository": "novox/mesh-catalog" + }, + { + "commit": "b112adcecc41d18f54190cbd2fc864199b70c518", + "fp": "src2:7dd0db8e98224f46121a5ae05c0b9c5a430eff531a5120aa40b1a59237853a3f", + "module": "amqp-email-forwarder", + "path": ".", + "repository": "jschoubben/amqp-email-forwarder" + }, + { + "commit": "dd458cb155f5f5fa3ed824a23728e444be5b156a", + "fp": "src2:a98f8152f0a837dd5ba883a15efaf110ba88100193157e4bdb1a93ba55209371", + "module": "artifact-store-tools", + "path": "modules/artifact-store-tools", + "repository": "novox/mesh-catalog" + } + ] + } + }, + { + "name": "the module verb (captured: xclip)", + "of": "verb:module.answer", + "given": { + "module": { + "commit": "f469eb387a3015569d9b16c66ed418e9dd098f64", + "fp": "src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56", + "manifest": { + "build": { + "artifacts": [ + { + "binary": "xclip-tools", + "from": "cmd/xclip-tools", + "kind": "bundle", + "language": "go", + "loads": [ + "xclip-tools" + ], + "name": "tools", + "system": "arch" + } + ] + }, + "capabilities": [ + "package-manager" + ], + "module": "xclip", + "resources": [ + { + "id": "package", + "package": "xclip", + "type": "package" + } + ], + "tools": [ + "xclip_copy", + "xclip_paste", + "xclip_targets", + "xclip_session" + ], + "version": "1" + }, + "module": "xclip", + "path": "modules/xclip", + "repository": "novox/mesh-catalog" + } + } } ] diff --git a/contracts/seats/mesh-catalog/seat.json b/contracts/seats/mesh-catalog/seat.json index d036149a..9fb8846c 100644 --- a/contracts/seats/mesh-catalog/seat.json +++ b/contracts/seats/mesh-catalog/seat.json @@ -16,7 +16,47 @@ }, "answer": { "type": "object", - "description": "what modules answers, as its description says" + "description": "every module the trunk holds, by name", + "properties": { + "modules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the module's directory in its repository, '.' for a root" + }, + "commit": { + "type": "string", + "description": "the merge commit it was last changed at" + }, + "fp": { + "type": "string", + "description": "its fingerprint (src2: and 64 hex digits); absent when it has none" + }, + "none": { + "type": "string", + "description": "why it has none" + } + }, + "required": [ + "module", + "repository", + "commit" + ] + } + } + }, + "required": [ + "modules" + ] } }, { @@ -37,7 +77,48 @@ }, "answer": { "type": "object", - "description": "what module answers, as its description says" + "description": "the module whole", + "properties": { + "module": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the module's directory in its repository, '.' for a root" + }, + "commit": { + "type": "string", + "description": "the merge commit it was last changed at" + }, + "fp": { + "type": "string", + "description": "its fingerprint (src2: and 64 hex digits); absent when it has none" + }, + "none": { + "type": "string", + "description": "why it has none" + }, + "manifest": { + "type": "object", + "description": "the module's manifest as the trunk holds it" + } + }, + "required": [ + "module", + "repository", + "commit" + ] + } + }, + "required": [ + "module" + ] } }, { @@ -84,7 +165,7 @@ }, { "name": "seat-definitions", - "description": "The seat list (ADR 0636 §4): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", + "description": "The seat list (ADR 0636 \u00a74): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", "optional": true, "input": { "properties": { @@ -163,7 +244,7 @@ } } ], - "description": "The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4).", + "description": "The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 \u00a71, \u00a74).", "family": { "name": "mesh-catalog.events", "max-age-days": 7 @@ -344,8 +425,9 @@ "toolchains": { "type": "object", "additionalProperties": { - "description": "toolchain used" - } + "$ref": "#/defs/ToolchainUsed" + }, + "description": "by language: the toolchain FP2 was computed with" }, "paths": { "type": "object", @@ -543,7 +625,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -576,7 +659,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" }, "list": { "description": "the foundation's predefined list as kept" @@ -670,7 +754,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -735,7 +820,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -830,7 +916,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -904,7 +991,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -921,7 +1009,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -935,5 +1024,29 @@ "description": "the published packages", "history": 5 } - ] + ], + "defs": { + "ToolchainUsed": { + "type": "object", + "description": "a toolchain a fingerprint was computed with (ADR 0405 clause 7)", + "properties": { + "language": { + "type": "string" + }, + "version": { + "type": "string", + "description": "the installed toolchain's version" + }, + "recipe": { + "type": "string", + "description": "the build module's recipe version" + } + }, + "required": [ + "language", + "version", + "recipe" + ] + } + } } diff --git a/contracts/ts/seats/meshcatalog/src/index.ts b/contracts/ts/seats/meshcatalog/src/index.ts index 91fdcf23..4b590fe3 100644 --- a/contracts/ts/seats/meshcatalog/src/index.ts +++ b/contracts/ts/seats/meshcatalog/src/index.ts @@ -156,12 +156,36 @@ export function parsePackagesByPackageSeriesKey(key: string): { packagePart: str return { packagePart, series }; } +/** a toolchain a fingerprint was computed with (ADR 0405 clause 7) */ +export interface ToolchainUsed { + "language": string; + /** the build module's recipe version */ + "recipe": string; + /** the installed toolchain's version */ + "version": string; +} + /** is what modules takes. */ export interface ModulesInput { } +export interface ModulesAnswerModulesItem { + /** the merge commit it was last changed at */ + "commit": string; + /** its fingerprint (src2: and 64 hex digits); absent when it has none */ + "fp"?: string; + "module": string; + /** why it has none */ + "none"?: string; + /** the module's directory in its repository, '.' for a root */ + "path"?: string; + "repository": string; +} + /** is what modules answers. */ -export type ModulesAnswer = Record; +export interface ModulesAnswer { + "modules": ModulesAnswerModulesItem[]; +} /** is what module takes. */ export interface ModuleInput { @@ -169,8 +193,25 @@ export interface ModuleInput { "module": string; } +export interface ModuleAnswerModule { + /** the merge commit it was last changed at */ + "commit": string; + /** its fingerprint (src2: and 64 hex digits); absent when it has none */ + "fp"?: string; + /** the module's manifest as the trunk holds it */ + "manifest"?: Record; + "module": string; + /** why it has none */ + "none"?: string; + /** the module's directory in its repository, '.' for a root */ + "path"?: string; + "repository": string; +} + /** is what module answers. */ -export type ModuleAnswer = Record; +export interface ModuleAnswer { + "module": ModuleAnswerModule; +} /** is what provides takes. */ export interface ProvidesInput { @@ -279,7 +320,8 @@ export interface ModulesByModuleValue { "repository": string; /** the source revision */ "src"?: number; - "toolchains"?: Record; + /** by language: the toolchain FP2 was computed with */ + "toolchains"?: Record; "unknown"?: string[]; /** the layout version */ "v"?: number; diff --git a/sdk/graph/graph_test.go b/sdk/graph/graph_test.go index e5c7fdd0..d590041a 100644 --- a/sdk/graph/graph_test.go +++ b/sdk/graph/graph_test.go @@ -15,6 +15,7 @@ import ( // TestGraphEdges: the module graph's cycle, dependents and providers, each from the edges alone (ADR 0603 §2, ADR 0604 §1). func TestGraphEdges(t *testing.T) { t.Run("a node's value", nodeValues) + t.Run("a reader of the seat list waits for its holder", readersOfTheSeatList) fp := "src2:" + strings.Repeat("b", 64) node := func(repository, path string, edit func(*Node)) Node { n := Node{FP: fp, Repository: repository, Path: path} @@ -252,3 +253,41 @@ func nodeValues(t *testing.T) { } } } + +// readersOfTheSeatList (D-31): a module whose manifest invokes the seat mesh-catalog's seat-definitions waits, by the verb +// edge, for the module that claims the seat and serves the verb, so the delivery plan puts the holder first (ADR 0614 §2); a +// foundation part the holder itself invokes lies on a cycle with it and the edge is left out, so the part is carried by the +// bootstrapped three until the holder's new build runs (ADR 0636 §1). +func readersOfTheSeatList(t *testing.T) { + const list = "seat:mesh-catalog.seat-definitions" + fp := "src2:" + strings.Repeat("c", 64) + node := func(edit func(*Node)) Node { + n := Node{FP: fp, Repository: "novox/mesh-delivery", Path: "modules/x"} + edit(&n) + return n + } + g := Graph{ + "indexer": node(func(n *Node) { + n.Serves = []string{list, "seat:mesh-catalog.modules"} + n.Invokes = []string{"seat:node-engine.composed"} + }), + "checker": node(func(n *Node) { n.Invokes = []string{list} }), + "node-agent": node(func(n *Node) { n.Invokes = []string{list}; n.Serves = []string{"seat:node-engine.composed"} }), + } + if got := g.Servers(list); !reflect.DeepEqual(got, []string{"indexer"}) { + t.Errorf("servers of the seat list = %v", got) + } + if got := g.Before("checker"); !reflect.DeepEqual(got, []string{"indexer"}) { + t.Errorf("the checker waits for %v, want the indexer", got) + } + g.LeaveOut() + if got := g.Before("checker"); !reflect.DeepEqual(got, []string{"indexer"}) { + t.Errorf("after the cycles are left out the checker waits for %v, want the indexer", got) + } + if got := g["node-agent"].LeftOut; !reflect.DeepEqual(got, []string{list}) { + t.Errorf("the node-agent leaves out %v, want the seat list (a cycle with the holder)", got) + } + if got := g.Before("node-agent"); len(got) != 0 { + t.Errorf("the node-agent waits for %v, want nothing: the bootstrap carries it", got) + } +}