diff --git a/go/catalogue/kept_until_delivered.go b/go/catalogue/kept_until_delivered.go index c5205e1..0f0e0fa 100644 --- a/go/catalogue/kept_until_delivered.go +++ b/go/catalogue/kept_until_delivered.go @@ -2,10 +2,11 @@ package catalogue // This file keeps, deprecated, the exported names feat/l3b-bus removed from this package while a running part still // uses them: an exported name is removed only after every user of it is delivered. The checker builds the running -// node-engine's composer (mesh-host main) beside this mesh-sdk (ADR 0548 §6), and mesh-host main, mesh-delivery main -// (the indexer's judge, the assigner's and mesh-grants' code and tests) and mesh-tools main (node-tools' tests) name -// them. Nothing in L3's own heads uses any of them. The second group removes this file, and its exemption in -// TestNoCatalogueManifestNamesAmqp, once mesh-host a24a1d72 and L3's mesh-delivery and mesh-tools heads run. +// node-engine's composer (mesh-host main) beside this mesh-sdk (ADR 0548 §6), and mesh-host main names them +// (RuntimeModule, CoreCredentialModules, FromBus). Nothing in the final L3 release's heads uses any of them, but that +// release still builds beside mesh-host 798d73e, so it keeps this file. The next release that changes mesh-sdk, once +// the final L3 release's node-engine runs on every node, removes this file and its exemption in +// TestNoCatalogueManifestNamesAmqp. import ( "fmt" diff --git a/go/catalogue/manifest_reads.go b/go/catalogue/manifest_reads.go new file mode 100644 index 0000000..e0d5851 --- /dev/null +++ b/go/catalogue/manifest_reads.go @@ -0,0 +1,394 @@ +package catalogue + +import ( + "bytes" + _ "embed" + "encoding/json" + "reflect" + "slices" + "sort" + "strings" +) + +// A reader capability is one thing of a manifest a reader of manifests (a node-engine) can read (novox/hq ADR 0614 §4): +// a manifest word, a form, or a seat name of the shared contract it is built with. Each is a string of one of three +// kinds: +// +// - `word:`, a key the lenient decoder reads, by its path of keys from the top of the manifest: `word:claims`, +// `word:claims.serves`. The key of a map (a setting's name, a requirement's) is no part of a path, and an entry of +// a list is read as the list: `word:settings.default`, `word:provides.credential`. +// - `form:`, a form a manifest may be written in: the config form (`form:config`, ADR 0348 §1) and each +// reference form a value may hold, `${:…}` (`form:setting`, `form:dir`). +// - `seat:`, a seat of the contract's seat table, as a manifest names one in a claim, a use, an invoke or a +// bucket's share. +// +// ManifestReads is what this contract reads; ManifestNeeds what one manifest's own words, forms and seat names need; +// BaselineManifestReads what a node-engine that states none is read as stating. What a module's build reads once it +// runs is never asked. +const ( + ReadWord = "word:" + ReadForm = "form:" + ReadSeat = "seat:" +) + +// referenceForms are the reference forms a manifest's values may hold, `${:…}`, as this contract fills them. A new +// reference form is added here in the change that teaches the contract to read it, so a node-engine built with it +// states it (ADR 0612 §2's new reference form is one). +var referenceForms = []string{"access", "backup", "bound", "collected", "config", "consumer", "consumers", + "contribution", "dir", "machine", "port", "seat", "secret", "setting"} + +// manifestTopWords are the top-level keys Manifest.UnmarshalJSON reads apart from the fields of manifestFields: each +// read whole, its inside a map of the module's own names (requirements, local names) and values, no words of the +// contract's beneath it. `requires` and `config` are read as their fields are (manifestNeeds). +var manifestTopWords = []string{"secrets", "contributes", OptionalRequiresKey, RequireSharesKey} + +// wireKeys are the keys of a block whose decoder reads its object by keys of its own, not by its fields' tags (its +// UnmarshalJSON): the keys as that decoder reads them, none of them a block. +var wireKeys = map[reflect.Type][]string{ + reflect.TypeOf(OfferIdentity{}): {"max", "in"}, + reflect.TypeOf(DataBackup{}): {"dump", "into"}, + reflect.TypeOf(OwnSecret{}): {"path", "taken", "issued-by", "prompt", "owner"}, +} + +// ManifestReads is every reader capability of this contract, sorted: what a node-engine built with it states in its +// node record (delivery.NodeRecord.ManifestReads). +func ManifestReads() []string { + set := map[string]bool{} + for _, w := range contractWords() { + set[ReadWord+w] = true + } + for _, f := range referenceForms { + set[ReadForm+f] = true + } + for _, s := range DefaultSeats() { + set[ReadSeat+s.Name] = true + } + return sortedSet(set) +} + +// baselineManifestReads is the baseline, generated once from mesh-sdk f02f142 (the contract every node-engine ran +// before any stated what it reads) by ManifestReads as this file computes it: one reader capability a line. +// +//go:embed manifest_reads_baseline.txt +var baselineManifestReads string + +// BaselineManifestReads is what a node-engine that states no reader capability is read as stating (novox/hq ADR 0614 +// §4): the words, forms and seat names of the shared contract as it stood before any node-engine stated them. +func BaselineManifestReads() []string { + var out []string + for _, line := range strings.Split(baselineManifestReads, "\n") { + if line = strings.TrimSpace(line); line != "" { + out = append(out, line) + } + } + return out +} + +// StatedManifestReads is what a node-engine's statement is read as: stated itself, or the baseline when it states none +// (an engine built before the statement existed). +func StatedManifestReads(stated []string) []string { + if len(stated) == 0 { + return BaselineManifestReads() + } + return stated +} + +// MissingManifestReads is each of needs that stated, read as StatedManifestReads reads it, lacks: sorted, nil when it +// lacks none. +func MissingManifestReads(needs, stated []string) []string { + have := map[string]bool{} + for _, s := range StatedManifestReads(stated) { + have[s] = true + } + var missing []string + for _, n := range needs { + if !have[n] { + missing = append(missing, n) + } + } + sort.Strings(missing) + return slices.Compact(missing) +} + +// ManifestNeeds is every reader capability a manifest's own words, forms and seat names need, sorted, read as the +// lenient decoder walks the manifest: only what this contract knows is named, since what it does not know is set aside +// (Manifest.SetAside). An error is only bytes that are no JSON object. +func ManifestNeeds(raw []byte) ([]string, error) { + var top map[string]any + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&top); err != nil { + return nil, err + } + used := map[string]bool{} + for _, f := range referenceForms { + if bytes.Contains(raw, []byte("${"+f+":")) { + used[ReadForm+f] = true + } + } + if _, has := top[ConfigField]; has || takesConfigKey.Match(raw) { + used[ReadForm+"config"] = true + } + words := map[string]bool{} + fields := structFields(reflect.TypeOf(manifestFields{})) + for _, k := range sortedAnyKeys(top) { + v := top[k] + switch { + case k == ConfigField: + // The config form is the settings form's words under another key (config.go). + words[SettingsField] = true + walkValue(v, fields[SettingsField].Type, SettingsField, words) + case k == "requires": + words[k] = true + for _, e := range asList(v) { + obj, isObj := e.(map[string]any) + if !isObj { + continue + } + if _, bus := obj["storage"]; bus { + // A bus-state entry of `requires` (BusStateEntry): the stored form's `bus-state`. + words[BusStateField] = true + walkValue(obj, reflect.TypeOf(BusStateEntry{}), BusStateField, words) + continue + } + walkValue(obj, reflect.TypeOf(requirementForm{}), k, words) + } + case slices.Contains(manifestTopWords, k): + words[k] = true + default: + if f, known := fields[k]; known { + words[k] = true + walkValue(v, f.Type, k, words) + } + } + } + for w := range words { + used[ReadWord+w] = true + } + for _, s := range seatNamesUsed(top) { + used[ReadSeat+s] = true + } + return sortedSet(used), nil +} + +// seatNamesUsed is each seat of the contract's table the manifest names: in a claim, a use, an invoke of a seat's verb, +// and a bucket's share with a seat. +func seatNamesUsed(top map[string]any) []string { + known := map[string]bool{} + for _, s := range DefaultSeats() { + known[s.Name] = true + } + set := map[string]bool{} + add := func(name string) { + if known[name] { + set[name] = true + } + } + for _, c := range asList(top["claims"]) { + if obj, ok := c.(map[string]any); ok { + name, _ := obj["name"].(string) + add(name) + } + } + for _, u := range asList(top["uses"]) { + name, _ := u.(string) + add(name) + } + for _, i := range asList(top["invokes"]) { + text, _ := i.(string) + if seat, _, ok := SeatInvoke(text); ok { + add(seat) + } + } + for _, r := range asList(top["requires"]) { + obj, _ := r.(map[string]any) + for _, share := range asList(obj["shares"]) { + text, _ := share.(string) + if seat, ok := strings.CutPrefix(text, "seat:"); ok { + add(seat) + } + } + } + return sortedSet(set) +} + +// contractWords is every word of this contract, by path (ReadWord): the fields of manifestFields, the keys the +// manifest's own decoder reads beside them, and the long form of a requirement. +func contractWords() []string { + set := map[string]bool{} + walkType(reflect.TypeOf(manifestFields{}), "", set, map[reflect.Type]bool{}) + set["requires"] = true + walkType(reflect.TypeOf(requirementForm{}), "requires", set, map[reflect.Type]bool{}) + for _, w := range manifestTopWords { + set[w] = true + } + return sortedSet(set) +} + +// walkType adds every word of t at path to set: a struct's fields by their keys, a list's and a map's elements as +// themselves; onPath stops a type that holds itself. +func walkType(t reflect.Type, path string, set map[string]bool, onPath map[reflect.Type]bool) { + t = deref(t) + if keys, custom := wireKeys[t]; custom { + for _, k := range keys { + set[joinWord(path, k)] = true + } + return + } + switch t.Kind() { + case reflect.Struct: + if onPath[t] { + return + } + onPath[t] = true + defer delete(onPath, t) + for name, f := range structFields(t) { + word := joinWord(path, name) + set[word] = true + walkType(f.Type, word, set, onPath) + } + case reflect.Slice, reflect.Array, reflect.Map: + if t.Elem().Kind() == reflect.Uint8 { + return // bytes: a value, never a block + } + walkType(t.Elem(), path, set, onPath) + } +} + +// walkValue adds to used every word of t that v, a value decoded from JSON, has at path. +func walkValue(v any, t reflect.Type, path string, used map[string]bool) { + t = deref(t) + if keys, custom := wireKeys[t]; custom { + obj, _ := v.(map[string]any) + for _, k := range keys { + if _, has := obj[k]; has { + used[joinWord(path, k)] = true + } + } + return + } + switch t.Kind() { + case reflect.Struct: + obj, isObj := v.(map[string]any) + if !isObj { + return // a short form (a name for an offer): no words inside it + } + fields := structFields(t) + for _, k := range sortedAnyKeys(obj) { + f, known := fields[k] + if !known { + continue // set aside by the decoder, so no word of this contract + } + word := joinWord(path, k) + used[word] = true + walkValue(obj[k], f.Type, word, used) + } + case reflect.Slice, reflect.Array: + if t.Elem().Kind() == reflect.Uint8 { + return + } + for _, item := range asList(v) { + walkValue(item, t.Elem(), path, used) + } + case reflect.Map: + obj, _ := v.(map[string]any) + for _, k := range sortedAnyKeys(obj) { + walkValue(obj[k], t.Elem(), path, used) + } + } +} + +// structFields are a struct's fields by the key the JSON decoder reads each by: its tag's name, else its own name; an +// untagged embedded struct's fields as the struct's own; never one tagged "-" or unexported. +func structFields(t reflect.Type) map[string]reflect.StructField { + out := map[string]reflect.StructField{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + tag := f.Tag.Get("json") + name, _, _ := strings.Cut(tag, ",") + if name == "-" { + continue + } + if f.Anonymous && name == "" && deref(f.Type).Kind() == reflect.Struct { + for k, inner := range structFields(deref(f.Type)) { + if _, shadowed := out[k]; !shadowed { + out[k] = inner + } + } + continue + } + if !f.IsExported() { + continue + } + if name == "" { + name = f.Name + } + out[name] = f + } + return out +} + +// untaggedBlocks are the struct types among the contract's words whose fields carry no key of their own and which +// wireKeys does not name: their words would be the Go names, which no decoder reads. A row keeps it empty. +func untaggedBlocks() []string { + seen := map[reflect.Type]bool{} + var out []string + var walk func(t reflect.Type) + walk = func(t reflect.Type) { + t = deref(t) + if seen[t] { + return + } + seen[t] = true + if _, custom := wireKeys[t]; custom { + return + } + switch t.Kind() { + case reflect.Struct: + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.IsExported() && !f.Anonymous && f.Tag.Get("json") == "" { + out = append(out, t.String()) + break + } + } + for _, f := range structFields(t) { + walk(f.Type) + } + case reflect.Slice, reflect.Array, reflect.Map: + walk(t.Elem()) + } + } + walk(reflect.TypeOf(manifestFields{})) + walk(reflect.TypeOf(requirementForm{})) + sort.Strings(out) + return out +} + +func deref(t reflect.Type) reflect.Type { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} + +func joinWord(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} + +func asList(v any) []any { + list, _ := v.([]any) + return list +} + +func sortedSet(set map[string]bool) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/go/catalogue/manifest_reads_baseline.txt b/go/catalogue/manifest_reads_baseline.txt new file mode 100644 index 0000000..15f8e45 --- /dev/null +++ b/go/catalogue/manifest_reads_baseline.txt @@ -0,0 +1,334 @@ +form:access +form:backup +form:bound +form:collected +form:config +form:consumer +form:consumers +form:contribution +form:dir +form:machine +form:port +form:seat +form:secret +form:setting +seat:git +seat:mesh-artifact-store +seat:mesh-artifact-sweep +seat:mesh-broker +seat:mesh-catalog +seat:mesh-conditions +seat:mesh-config +seat:mesh-delivery +seat:mesh-delivery-checker +seat:mesh-dns-resolver +seat:mesh-enrolment +seat:mesh-hand-acts +seat:mesh-healers +seat:mesh-placement +seat:mesh-provisioner +seat:mesh-self-check +seat:mesh-store +seat:mesh-vault +seat:mesh-wants +seat:mesh-watchdogs +seat:node-backup +seat:node-bar +seat:node-build-go +seat:node-build-image +seat:node-build-node +seat:node-clipboard +seat:node-compositor +seat:node-container-runtime +seat:node-display-server +seat:node-display-session +seat:node-engine +seat:node-environment +seat:node-files +seat:node-hostname +seat:node-hotkeys +seat:node-intrusion-prevention +seat:node-launcher +seat:node-lock-screen +seat:node-login-manager +seat:node-login-shell +seat:node-message-bus +seat:node-mounts +seat:node-nfs-server +seat:node-notifier +seat:node-package-manager +seat:node-packet-filter +seat:node-power +seat:node-provisioner +seat:node-reads +seat:node-resolver +seat:node-secret-service +seat:node-service-manager +seat:node-terminal-emulator +seat:node-uplink +seat:npm-package-registry +seat:the-private-network +word:accesses +word:accesses.id +word:accesses.mode +word:accesses.path +word:binds +word:build +word:build.artifacts +word:build.artifacts.binary +word:build.artifacts.bus +word:build.artifacts.bus.asks +word:build.artifacts.bus.emits +word:build.artifacts.bus.invokes +word:build.artifacts.bus.queues +word:build.artifacts.bus.removes +word:build.artifacts.bus.state +word:build.artifacts.compiles +word:build.artifacts.context +word:build.artifacts.context.ref +word:build.artifacts.context.repository +word:build.artifacts.context.seat +word:build.artifacts.entrypoints +word:build.artifacts.env +word:build.artifacts.external +word:build.artifacts.file +word:build.artifacts.from +word:build.artifacts.kind +word:build.artifacts.language +word:build.artifacts.loads +word:build.artifacts.name +word:build.artifacts.names-on-purpose +word:build.artifacts.paths +word:build.artifacts.sha256 +word:build.artifacts.system +word:build.artifacts.target +word:build.artifacts.url +word:build.artifacts.version +word:build.artifacts.within +word:build.on +word:build.on.arg +word:build.on.artifact +word:build.on.image +word:build.on.module +word:bundles +word:bundles.binary +word:bundles.bus +word:bundles.bus.asks +word:bundles.bus.emits +word:bundles.bus.invokes +word:bundles.bus.queues +word:bundles.bus.removes +word:bundles.bus.state +word:bundles.digest +word:bundles.entrypoints +word:bundles.env +word:bundles.language +word:bundles.launchers +word:bundles.loads +word:bundles.name +word:bundles.source +word:bus-users +word:capabilities +word:certificate +word:certificate.authority +word:certificate.into +word:claims +word:claims.capabilities +word:claims.kind +word:claims.name +word:claims.renders +word:claims.scope +word:claims.serves +word:collections +word:collections.comment +word:collections.name +word:computed +word:consumes +word:contributes +word:contributions +word:contributions.content +word:contributions.data +word:contributions.if-capability +word:contributions.kind +word:contributions.seat +word:data +word:data.consumers +word:data.consumers.class +word:data.consumers.in +word:data.consumers.why +word:data.kept-by +word:data.kept-by.class +word:data.kept-by.why +word:data.own +word:data.own.active +word:data.own.backup +word:data.own.backup.dump +word:data.own.backup.into +word:data.own.class +word:data.own.id +word:data.own.measure +word:data.own.path +word:data.own.redundancy +word:data.own.why +word:data.own.within +word:emits +word:environment +word:environment.path +word:environment.path.at +word:environment.path.entry +word:environment.variables +word:facts +word:facts.home +word:facts.path +word:facts.shared +word:facts.template +word:filtering +word:filtering.into +word:grants +word:guards +word:handed +word:handed.digest +word:handed.items +word:handed.source +word:handed.templates +word:instances +word:invokes +word:items +word:items.attributes +word:items.collection +word:items.content +word:items.if-capability +word:items.name +word:items.template +word:jailing +word:jailing.filter-into +word:jailing.into +word:jails +word:jails.failregex +word:jails.jail +word:jails.name +word:keeps +word:listens +word:listens.fixed +word:listens.from +word:listens.name +word:listens.port +word:listens.protocol +word:listens.why +word:logs +word:logs.max-age-days +word:logs.max-mib +word:logs.name +word:module +word:moved +word:objects +word:objects.grant +word:objects.grant.config +word:objects.max-mib +word:objects.name +word:optional-requires +word:own-secrets +word:own-secrets.issued-by +word:own-secrets.owner +word:own-secrets.path +word:own-secrets.prompt +word:own-secrets.taken +word:prepares +word:provides +word:provides.answered-by +word:provides.credential +word:provides.credential.form +word:provides.credential.issued-by +word:provides.credential.own +word:provides.credential.tool +word:provides.identity +word:provides.identity.in +word:provides.identity.max +word:provides.keeps-consumer-data +word:provides.name +word:provides.reach +word:provides.scope +word:reads +word:receives +word:replaces +word:requires +word:requires-shares +word:requires.name +word:requires.optional +word:requires.shares +word:requires.shares.methods +word:requires.shares.to +word:resources +word:runs-as +word:runtime-health +word:seats +word:seats.accepts +word:seats.by-caller +word:seats.emits +word:seats.kinded +word:seats.name +word:seats.needs +word:seats.paths +word:seats.proofs +word:seats.records +word:seats.retain-seconds +word:seats.scope +word:seats.serves +word:seats.serves.description +word:seats.serves.input +word:seats.serves.name +word:seats.serves.output +word:seats.serves.replaces +word:secrets +word:secrets-owner +word:serves +word:settings +word:settings.default +word:settings.description +word:settings.enum +word:settings.kind +word:settings.maximum +word:settings.minimum +word:settings.pattern +word:settings.predefined +word:settings.required +word:settings.runtime +word:settings.trusted +word:settings.type +word:settings.why +word:shell +word:shell.code +word:shell.for +word:shell.slot +word:slug +word:state +word:state.history +word:state.key-ttl +word:state.name +word:state.per-machine +word:state.ttl-seconds +word:streams +word:streams.duplicates +word:streams.events +word:streams.grant +word:streams.grant.config +word:streams.grant.events +word:streams.max-age-days +word:streams.max-deliveries +word:streams.max-mib +word:streams.name +word:streams.retention +word:templates +word:tools +word:unhealthy +word:unhealthy.severity +word:unhealthy.why +word:upgrade +word:upgrade.policy +word:upgrade.why +word:uses +word:version +word:waits +word:zone +word:zone.listen +word:zone.name diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index 6f669b6..fda8140 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -2,6 +2,7 @@ package catalogue import ( "os" + "slices" "testing" ) @@ -26,4 +27,71 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { t.Errorf("artifact %q names paths %v: the running controller refuses the word until R4e is delivered", a.Name, a.Paths) } } + + // What a reader reads (novox/hq ADR 0614 §4): the baseline is the contract of mesh-sdk f02f142, which every running + // node-engine was built with, and this contract reads all of it; the words added since are the only ones it lacks. + reads, baseline := ManifestReads(), BaselineManifestReads() + var added []string + for _, r := range reads { + if !slices.Contains(baseline, r) { + added = append(added, r) + } + } + for _, b := range baseline { + if !slices.Contains(reads, b) { + t.Errorf("the baseline's %s is no reader capability of this contract", b) + } + } + for _, want := range []string{"word:tool-runner", "word:core-credentials", "word:bus-state", "word:bus-state.storage"} { + if !slices.Contains(added, want) { + t.Errorf("%s is in the baseline, or not read: added since it are %v", want, added) + } + } + for _, want := range []string{"word:claims.serves", "word:settings.default", "form:config", "form:setting", "seat:mesh-catalog"} { + if !slices.Contains(baseline, want) { + t.Errorf("the baseline lacks %s", want) + } + } + if blocks := untaggedBlocks(); len(blocks) != 0 { + t.Errorf("blocks whose words are their Go names, no decoder's keys: %v (name their keys in wireKeys)", blocks) + } + // What a manifest's own words, forms and seat names need, beyond the baseline. + for _, row := range []struct { + why, raw string + needs []string // among its needs + missing []string // what the baseline lacks of them + }{ + {"a tool runner", `{"module":"r","version":"1","tool-runner":true}`, []string{"word:tool-runner"}, []string{"word:tool-runner"}}, + {"a manifest of the baseline's words", `{"module":"a","version":"1","claims":[{"name":"mesh-catalog","scope":"mesh",` + + `"serves":["modules"]}],"invokes":["seat:mesh-delivery.show"],"config":{"x":{"type":"string","default":""}},` + + `"resources":[{"id":"f","type":"file","content":"${config:x} ${count:-0}"}],"provides":["plain",{"name":"p",` + + `"identity":{"max":20,"in":"x"}}]}`, + []string{"word:claims.serves", "word:settings.type", "form:config", "seat:mesh-catalog", "seat:mesh-delivery", + "word:provides.identity.max", "word:resources"}, nil}, + {"a bus-state entry of requires", `{"module":"b","version":"1","requires":["mesh-bus",{"name":"jobs","storage":"kv"}]}`, + []string{"word:requires", "word:bus-state.storage"}, []string{"word:bus-state", "word:bus-state.name", "word:bus-state.storage"}}, + {"a word this contract does not know", `{"module":"u","version":"1","newer-word":{"inside":1}}`, []string{"word:module"}, nil}, + } { + needs, err := ManifestNeeds([]byte(row.raw)) + if err != nil { + t.Fatalf("%s: %v", row.why, err) + } + for _, n := range row.needs { + if !slices.Contains(needs, n) { + t.Errorf("%s: needs %v, not %s", row.why, needs, n) + } + } + if slices.ContainsFunc(needs, func(n string) bool { return n == "word:newer-word" || n == "form:count" }) { + t.Errorf("%s: needs %v, naming what the contract does not read", row.why, needs) + } + if got := MissingManifestReads(needs, nil); !slices.Equal(got, row.missing) { + t.Errorf("%s: an engine stating nothing lacks %v, want %v", row.why, got, row.missing) + } + if got := MissingManifestReads(needs, reads); len(got) != 0 { + t.Errorf("%s: this contract lacks %v", row.why, got) + } + } + if got := MissingManifestReads([]string{"seat:mesh-catalog", "word:module"}, []string{"word:module"}); !slices.Equal(got, []string{"seat:mesh-catalog"}) { + t.Errorf("an engine stating its own reads is read by them alone: lacks %v", got) + } } diff --git a/go/delivery/assigner.go b/go/delivery/assigner.go index 3f439a2..1e26c9c 100644 --- a/go/delivery/assigner.go +++ b/go/delivery/assigner.go @@ -146,9 +146,15 @@ const ( 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, which a later release gives numbers of their own, so a want of that release reads on every node +// that runs this one. Nothing writes 9 or 10 yet: the verb wait is held under RuleProvisions and the capability hold +// under RuleComposed, each with words naming its edge. +const ruleRead = 10 + func checkRule(rule int, of string) error { - if rule < RuleBuildDone || rule > RuleProvisions { - return fmt.Errorf("delivery: %s names the rule %d, which is not one of the eight (1 to 8)", of, rule) + if rule < RuleBuildDone || rule > ruleRead { + return fmt.Errorf("delivery: %s names the rule %d, which is not one of 1 to %d", of, rule, ruleRead) } return nil } diff --git a/go/delivery/assigner_test.go b/go/delivery/assigner_test.go index b26279f..dfad9f2 100644 --- a/go/delivery/assigner_test.go +++ b/go/delivery/assigner_test.go @@ -79,6 +79,8 @@ func TestAssignerValuesRoundTrip(t *testing.T) { {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleCanary, Words: "the canary ace does not run it healthy yet"}, {Removed: true, Words: "the module left g14"}, {FP: fp2of('a'), Composed: hashOf('1'), Put: true, Hold: true, Words: "put by the operator: the bus's user list"}, + // A rule a later release numbers (9, 10: the verb wait, the capability hold, ADR 0614) reads here already. + {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 10, Words: "the verb x.y it invokes is served by no running build"}, } for _, w := range wants { data, err := EncodeWant(w) @@ -147,7 +149,7 @@ func TestAssignerValuesRefused(t *testing.T) { {Removed: true, FP: fp2of('a')}, {FP: fp2of('a'), Newest: fp2of('b')}, {FP: fp2of('a'), Newest: fp2of('a'), Rule: 3}, - {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 9}, + {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 11}, {FP: fp2of('a'), Composed: hashOf('1'), Hold: true}, {FP: fp2of('a'), Composed: hashOf('1'), Put: true, Newest: fp2of('b'), Rule: 3}, {Removed: true, Put: true}, @@ -164,7 +166,7 @@ func TestAssignerValuesRefused(t *testing.T) { {Want: fp2of('a'), Sent: fp2of('b'), Rule: 3, Unexplained: true}, {Want: fp2of('a'), Sent: fp2of('a'), Rule: 3}, {}, - {Want: fp2of('a'), Sent: fp2of('b'), Rule: 9}, + {Want: fp2of('a'), Sent: fp2of('b'), Rule: 11}, {WantRemoved: true, Want: fp2of('a'), Rule: 1}, } { if _, err := EncodeDifference(d); err == nil { diff --git a/go/delivery/noderecord.go b/go/delivery/noderecord.go index 423cf6b..52fa5f3 100644 --- a/go/delivery/noderecord.go +++ b/go/delivery/noderecord.go @@ -59,6 +59,10 @@ type NodeRecord struct { // key until the lease is handed away, and the vault's key and authority once it reads them from `signers`. The gate // before the lease write reads every node's from the bus. Trusts []string `json:"trusts,omitempty"` + // ManifestReads are the reader capabilities of the shared contract this machine's node-engine is built with + // (catalogue.ManifestReads, novox/hq ADR 0614 §4): the manifest words, forms and seat names it reads. Empty for an + // engine built before it said them, which a reader takes as the baseline (catalogue.StatedManifestReads). + ManifestReads []string `json:"manifest-reads,omitempty"` } func (r NodeRecord) check() error { @@ -70,6 +74,11 @@ func (r NodeRecord) check() error { return fmt.Errorf("delivery: the record of %s names an empty capability", r.Node) } } + for _, c := range r.ManifestReads { + if c == "" { + return fmt.Errorf("delivery: the record of %s names an empty reader capability", r.Node) + } + } return nil } diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index a896628..2bdd675 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -21,7 +21,8 @@ var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|privat func TestNodeRecord(t *testing.T) { hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub", Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, - NetworksSaid: true, Contract: 3, Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"}} + NetworksSaid: true, Contract: 3, Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"}, + ManifestReads: []string{"seat:mesh-catalog", "word:tool-runner"}} raw, err := EncodeNodeRecord(hub) if err != nil { t.Fatal(err) @@ -45,6 +46,7 @@ func TestNodeRecord(t *testing.T) { for name, r := range map[string]NodeRecord{ "empty capability": {Node: "ace", Capabilities: []string{""}}, + "empty reader capability": {Node: "ace", ManifestReads: []string{""}}, "node that is not a name": {Node: "a.b"}, } { if _, err := EncodeNodeRecord(r); err == nil { diff --git a/go/delivery/source.go b/go/delivery/source.go index 99a4f68..3956884 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -443,6 +443,10 @@ type Module struct { // Warnings are the problems of an accepted manifest a stricter judge outgrew (novox/hq ADR 0579 §1): Verdict is then // empty (accepted), and the watchman raises delivery.ManifestOutgrown until the manifest is mended. Warnings []string `json:"warnings,omitempty"` + // Needs are the reader capabilities the manifest's own words, forms and seat names need (catalogue.ManifestNeeds, + // novox/hq ADR 0614 §4), written by the judge with its verdict: the assigner holds a node's want while that node's + // engine does not state one of them. Empty in a value written before, which needs nothing. + Needs []string `json:"needs,omitempty"` } // VerdictOf is the verdict a modules value carries, or nil when it carries none (taken as accepted, TakeManifestVerdict). diff --git a/go/delivery/source_test.go b/go/delivery/source_test.go index 1c90a60..838d395 100644 --- a/go/delivery/source_test.go +++ b/go/delivery/source_test.go @@ -181,6 +181,8 @@ func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3 for _, m := range []Module{ {FP: "src1:abc", Src: 5, FPSrc: 5, Repository: "novox/mesh-sdk", Commit: "cccc"}, {None: "a build that resolves packages when built", Repository: "novox/mesh-sdk", Commit: "cccc"}, + // The reader capabilities its manifest needs (ADR 0614 §4), written with the verdict. + {FP: "src1:abc", Repository: "novox/mesh-sdk", Commit: "cccc", Needs: []string{"seat:mesh-catalog", "word:tool-runner"}}, } { data, err = EncodeModule(m) check("module", data, err) diff --git a/go/delivery/want_composed_test.go b/go/delivery/want_composed_test.go index 77b9bf6..701022c 100644 --- a/go/delivery/want_composed_test.go +++ b/go/delivery/want_composed_test.go @@ -51,7 +51,7 @@ func TestAWantNamesItsComposition(t *testing.T) { {Want{Removed: true, Composed: hashOf('1')}, "removed want names no"}, {Want{FP: fp2of('a'), Composed: hashOf('1'), Backup: "later"}, `backup answer "later"`}, {Want{Removed: true, Backup: BackupAgain}, "removed want names no"}, - {Want{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 9}, "rule 9"}, + {Want{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 11}, "rule 11"}, } for _, r := range refusals { if _, err := EncodeWant(r.w); err == nil || !strings.Contains(err.Error(), r.why) {