From ccb367e68c3c9565f50a2dd560f332a90c4db8f9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 01:57:36 +0100 Subject: [PATCH 01/13] =?UTF-8?q?Decode=20a=20manifest=20leniently,=20so?= =?UTF-8?q?=20a=20field=20a=20reader=20does=20not=20know=20never=20leaves?= =?UTF-8?q?=20its=20module=20out=20(ADR=200578=20=C2=A73,=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1585, S1: catalogue.Decode sets an unknown field aside and names it (SetAside); only a manifest not read past the field is still left out. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/decode.go | 31 +++++++++++++++++++++ go/catalogue/manifest.go | 2 +- go/catalogue/setting_defaults_test.go | 40 ++++++++++++--------------- go/catalogue/unknown_field.go | 9 ++++-- 4 files changed, 56 insertions(+), 26 deletions(-) create mode 100644 go/catalogue/decode.go diff --git a/go/catalogue/decode.go b/go/catalogue/decode.go new file mode 100644 index 0000000..09008b6 --- /dev/null +++ b/go/catalogue/decode.go @@ -0,0 +1,31 @@ +package catalogue + +import ( + "encoding/json" + "strings" +) + +// Decode reads a manifest leniently, as every part but the judge reads one (novox/hq ADR 0578 §3): every field the +// contract knows as it declares it, and a field it does not know set aside and named (SetAside), never refused. It +// judges nothing: no name, seat, bus or catalogue rule is applied, since the indexer judged the manifest once and its +// verdict is in the indexer's state (delivery.VerdictsBucket); a reader takes the verdict beside it +// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, or a field of the wrong shape. +func Decode(raw []byte) (Manifest, error) { + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return Manifest{}, err + } + return m, nil +} + +// SetAside names what Decode set aside of this manifest: each field it does not know, as the decoder words the first +// and with the path of every one it read without, or "" when it knew every field. A reader says it in its own words +// (its journal, its finding) and reads the rest; the part that needs the field takes the newer package (ADR 0578 §6). +func (m Manifest) SetAside() string { return m.unknown } + +// ReadNoFurther is whether Decode could not read past a field it does not know, so the manifest holds its name and +// version alone: a reader holds such a module, as it holds a refused one, and names why (SetAside). +func (m Manifest) ReadNoFurther() bool { return strings.Contains(m.unknown, readNoFurther) } + +// readNoFurther marks, in what a manifest set aside, that it was not read past it. +const readNoFurther = "(read no further: " diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 5db0e07..c475a59 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -1680,7 +1680,7 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error { fields = manifestFields{} _ = json.Unmarshal(keys["module"], &fields.Module) _ = json.Unmarshal(keys["version"], &fields.Version) - unknown += " (read no further: " + perr.Error() + ")" + unknown += " " + readNoFurther + perr.Error() + ")" } else { unknown += " (read without " + strings.Join(pruned, ", ") + ")" } diff --git a/go/catalogue/setting_defaults_test.go b/go/catalogue/setting_defaults_test.go index d5e50da..1bdf7a9 100644 --- a/go/catalogue/setting_defaults_test.go +++ b/go/catalogue/setting_defaults_test.go @@ -298,10 +298,10 @@ func TestADefaultFillsAContributionAndAServedFactButNotALiteral(t *testing.T) { } } -// A stored manifest with a key this controller does not know, at the top or inside any block, is read -// and its module is left out of every declaration by name; the rest of the catalogue is read; the module -// check refuses it. One case per block whose decoder wraps the decoder's words in its own. -func TestAStoredManifestWithAnUnknownKeyIsLeftOutAndRegistrationRefusesIt(t *testing.T) { +// A stored manifest with a key this reader does not know, at the top or inside any block, is decoded with the key set +// aside and named, and its module is composed, never left out (novox/hq ADR 0578 §6, which changes ADR 0262); the judge +// refuses it. One case per block whose decoder wraps the decoder's words in its own. +func TestAStoredManifestWithAnUnknownKeyIsSetAsideAndTheJudgeRefusesIt(t *testing.T) { for where, raw := range map[string]string{ "the top": `{"module": "later", "version": "2", "a-field-from-later": {"x": 1}}`, "a state": `{"module": "later", "version": "2", "state": [{"name": "s", "a-field-from-later": 1}]}`, @@ -311,12 +311,12 @@ func TestAStoredManifestWithAnUnknownKeyIsLeftOutAndRegistrationRefusesIt(t *tes "an own secret": `{"module": "later", "version": "2", "own-secrets": {"s": {"path": "/x", "a-field-from-later": 1}}}`, "a seat's verb": `{"module": "later", "version": "2", "seats": [{"name": "later-seat", "serves": [{"name": "v", "a-field-from-later": 1}]}]}`, } { - var m Manifest - if err := json.Unmarshal([]byte(raw), &m); err != nil { + m, err := Decode([]byte(raw)) + if err != nil { t.Errorf("%s: the stored manifest was not read: %v", where, err) continue } - if m.Module != "later" || m.Version != "2" || !strings.Contains(m.UnknownField(), `"a-field-from-later"`) { + if m.Module != "later" || m.Version != "2" || !strings.Contains(m.SetAside(), `"a-field-from-later"`) || m.ReadNoFurther() { t.Errorf("%s: read as %q %q, unknown %q", where, m.Module, m.Version, m.UnknownField()) continue } @@ -324,12 +324,8 @@ func TestAStoredManifestWithAnUnknownKeyIsLeftOutAndRegistrationRefusesIt(t *tes t.Errorf("%s: the block's decoder did not say it: %q", where, m.UnknownField()) } left := Resolution{Node: "laptop", Modules: []Manifest{m, notifier()}}.LeftOut(nil, false) - if why := left["later"]; !strings.Contains(why, "uses a field this controller does not know") || - !strings.Contains(why, "a-field-from-later") { - t.Errorf("%s: not left out by name: %v", where, left) - } - if _, notifierLeft := left["notifier"]; notifierLeft { - t.Errorf("%s: another module was left out with it: %v", where, left) + if len(left) != 0 { + t.Errorf("%s: a module was left out for a field set aside: %v", where, left) } if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "a-field-from-later") { t.Errorf("%s: the module check took it: %v", where, err) @@ -373,14 +369,14 @@ func TestALeftOutModuleStillProvidesWhatItProvides(t *testing.T) { t.Errorf("unknown %q does not say it read without %s", m.UnknownField(), want) } } - if !strings.Contains(UnknownFieldReason(m), "left out") { - t.Fatal(UnknownFieldReason(m)) + if why := UnknownFieldReason(m); why != "" { + t.Fatal(why) } } -// A left-out module's data is still copied: the backup holder on its machine keeps its lines while every -// other thing of it is left out. -func TestALeftOutModulesDataIsStillBackedUp(t *testing.T) { +// A module with a field set aside is composed whole: its files are declared and its data backed up (novox/hq ADR 0578 +// §6; before, ADR 0262 left it out and backed up its data alone). +func TestAModuleWithAFieldSetAsideIsComposedWhole(t *testing.T) { var later Manifest if err := json.Unmarshal([]byte(`{"module": "later", "version": "2", "a-field-from-later": 1, "resources": [{"id": "d", "type": "directory", "mode": "0700"}, {"id": "rc", "type": "file", "path": "/etc/later.conf", "content": "x\n"}], @@ -397,12 +393,12 @@ func TestALeftOutModulesDataIsStillBackedUp(t *testing.T) { if err != nil { t.Fatal(err) } - if _, left := composed.LeftOut["later"]; !left { - t.Fatalf("not left out: %v", composed.LeftOut) + if _, left := composed.LeftOut["later"]; left { + t.Fatalf("left out: %v", composed.LeftOut) } got := byID(composed.Resources) - if _, declared := got["later.rc"]; declared { - t.Fatal("the left-out module's file is still declared") + if _, declared := got["later.rc"]; !declared { + t.Fatal("the module's file is not declared") } list, _ := got["backups.list"]["content"].(string) if !strings.Contains(list, "# later") || !strings.Contains(list, "path /var/lib/later/d") { diff --git a/go/catalogue/unknown_field.go b/go/catalogue/unknown_field.go index dc41465..6ce7f3d 100644 --- a/go/catalogue/unknown_field.go +++ b/go/catalogue/unknown_field.go @@ -50,10 +50,13 @@ func asUnknownField(err error) *UnknownFieldError { return nil } -// UnknownFieldReason is why a module whose stored manifest has a key this controller does not know is -// left out of a machine's declaration, or "" when it has none. +// UnknownFieldReason is why a module whose stored manifest has a key this reader does not know is left out of a +// machine's declaration, or "" when it is not. **A field set aside no longer leaves the module out** (novox/hq ADR 0578 +// §6, which changes ADR 0262 here): the lenient decoder reads the rest, the reader names the field (SetAside), and the +// part that needs the field takes the newer package. Only a manifest not read past the field, which holds its name and +// version alone (ReadNoFurther), is still left out, since nothing of it could be composed. func UnknownFieldReason(m Manifest) string { - if m.unknown == "" { + if !m.ReadNoFurther() { return "" } return m.Module + " uses a field this controller does not know (" + m.unknown + "); it is left out " + -- 2.54.0 From a34355755462abb4a03c2626fa82aaadac0e63c7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 01:57:36 +0100 Subject: [PATCH 02/13] =?UTF-8?q?Give=20the=20indexer's=20verdicts=20on=20?= =?UTF-8?q?manifests=20a=20contract=20readers=20take=20(ADR=200578=20?= =?UTF-8?q?=C2=A71,=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1585, S3: state mesh-delivery-indexer_verdicts keyed by manifest digest, event manifest-refused, a modules value carrying its verdict, and TakeManifestVerdict reading a missing verdict as accepted. Contract only, no rules. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/source.go | 21 +++++ go/delivery/verdicts.go | 152 +++++++++++++++++++++++++++++++++++ go/delivery/verdicts_test.go | 65 +++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 go/delivery/verdicts.go create mode 100644 go/delivery/verdicts_test.go diff --git a/go/delivery/source.go b/go/delivery/source.go index 3c09503..e9d28a0 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -415,6 +415,19 @@ type Module struct { // so the checker tells whether a change touches an artifact's build source without reading a manifest (the lead's // decision L8 of R4e). Absent for an artifact that names none, and in a value written before. ArtifactPaths map[string][]string `json:"paths,omitempty"` + // Verdict is the indexer's verdict on Manifest, VerdictAccepted or VerdictRefused, and Problems each problem of a + // refusal, as the verdicts state holds them (novox/hq ADR 0578 §1): a reader of modules takes it without a read of + // that state. Empty in a value written before the indexer judged, which a reader takes as accepted (TakeManifestVerdict). + Verdict string `json:"verdict,omitempty"` + Problems []string `json:"problems,omitempty"` +} + +// VerdictOf is the verdict a modules value carries, or nil when it carries none (taken as accepted, TakeManifestVerdict). +func (m Module) VerdictOf(module string) *ManifestVerdict { + if m.Verdict == "" { + return nil + } + return &ManifestVerdict{Module: module, State: m.Verdict, Problems: m.Problems} } // RootPath is Module.Path of a module whose manifest is its repository's root. @@ -503,6 +516,14 @@ func (m Module) check() error { return err } } + switch { + case m.Verdict != "" && m.Verdict != VerdictAccepted && m.Verdict != VerdictRefused: + return fmt.Errorf("delivery: the module's verdict %q is neither %s nor %s", m.Verdict, VerdictAccepted, VerdictRefused) + case m.Verdict != "" && m.Manifest == "": + return fmt.Errorf("delivery: a module carries a verdict and no manifest digest") + case (m.Verdict == VerdictRefused) != (len(m.Problems) > 0): + return fmt.Errorf("delivery: a module's problems are named only with a refusal, and a refusal names them") + } return checkNewForm(m.FP2, m.Manifest) } diff --git a/go/delivery/verdicts.go b/go/delivery/verdicts.go new file mode 100644 index 0000000..86de134 --- /dev/null +++ b/go/delivery/verdicts.go @@ -0,0 +1,152 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "time" +) + +// The verdicts on manifests (novox/hq ADR 0578 §1, §3): the indexer judges every manifest it takes in once, with its own +// judge, and writes the verdict in its own state; every other part decodes a manifest leniently (catalogue.Decode) and +// takes the verdict from here. This is the contract only: what a verdict holds, its key and its event. The rules that +// make a verdict are the judge's, a package of mesh-delivery the indexer and the checker use, and no reader runs them. + +const ( + // VerdictsBucket is the indexer's state of verdicts, one key per manifest digest (VerdictsKey). + VerdictsBucket = IndexerModule + "_verdicts" + // VerdictsHistory is the revisions the verdicts state keeps per key: a verdict is written again only when the judge + // that made it changed and the verdict with it. + VerdictsHistory = 5 + + // 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; Problems says each. + VerdictRefused = "refused" + + // ManifestRefusedEvent is the indexer's event saying a manifest was refused (ManifestRefused), so the watchman + // raises it as a condition beside the indexer's finding. + ManifestRefusedEvent = "manifest-refused" +) + +// ManifestVerdict is the value of a verdicts key: the module whose manifest it is, accepted or refused, every problem the judge +// found (none when accepted), the judge that made it (the indexer's build fingerprint) and when. +type ManifestVerdict struct { + V int `json:"v"` + Module string `json:"module"` + State string `json:"state"` + Problems []string `json:"problems,omitempty"` + Judge string `json:"judge"` + At time.Time `json:"at"` +} + +// Refused is whether v refuses its manifest. +func (v ManifestVerdict) Refused() bool { return v.State == VerdictRefused } + +// Same is whether v and o say the same of a manifest: the module, the state and the problems, whoever judged and when. +// The indexer writes a verdict only when it is not the same as the one it holds. +func (v ManifestVerdict) Same(o ManifestVerdict) bool { + if v.Module != o.Module || v.State != o.State || len(v.Problems) != len(o.Problems) { + return false + } + for i := range v.Problems { + if v.Problems[i] != o.Problems[i] { + return false + } + } + return true +} + +func (v ManifestVerdict) check() error { + if err := checkName("module", v.Module); err != nil { + return err + } + switch v.State { + case VerdictAccepted: + if len(v.Problems) > 0 { + return fmt.Errorf("delivery: the verdict on %s accepts its manifest and names problems", v.Module) + } + case VerdictRefused: + if len(v.Problems) == 0 { + return fmt.Errorf("delivery: the verdict on %s refuses its manifest and names no problem", v.Module) + } + default: + return fmt.Errorf("delivery: the verdict on %s is %q, neither %s nor %s", v.Module, v.State, VerdictAccepted, VerdictRefused) + } + if v.Judge == "" { + return fmt.Errorf("delivery: the verdict on %s names no judge", v.Module) + } + return nil +} + +// VerdictsKey is the key, in VerdictsBucket, of the verdict on a manifest: its digest (ManifestDigest), as in the +// manifests bucket. +func VerdictsKey(digest string) (string, error) { + if !isHex64(digest) { + return "", fmt.Errorf("delivery: %q is not a manifest digest, 64 lowercase hex digits", digest) + } + return digest, nil +} + +// EncodeManifestVerdict is v as JSON at Version; it refuses a verdict without a module or a judge, a state other than accepted +// or refused, a refusal naming no problem and an acceptance naming one. +func EncodeManifestVerdict(v ManifestVerdict) ([]byte, error) { + if err := v.check(); err != nil { + return nil, err + } + v.V = Version + return json.Marshal(v) +} + +// DecodeManifestVerdict reads a verdict; it refuses an unknown version (ErrUnknownVersion) and what EncodeManifestVerdict refuses. +func DecodeManifestVerdict(data []byte) (ManifestVerdict, error) { + var v ManifestVerdict + if err := decode(data, &v); err != nil { + return ManifestVerdict{}, err + } + if err := v.check(); err != nil { + return ManifestVerdict{}, err + } + return v, nil +} + +// TakeManifestVerdict is how a reader takes the verdict on a manifest (ADR 0578 §3): refused, with its problems, only when the +// indexer wrote a refusal. A missing verdict (nil) is taken as accepted, because every manifest the indexer holds before +// it has judged it was taken in under the judges that ran until then; that lasts until the indexer's first pass after its +// delivery has written every verdict. A reader holds a refused module and names it with Why, and reads the rest. +func TakeManifestVerdict(v *ManifestVerdict) (refused bool, problems []string) { + if v == nil || !v.Refused() { + return false, nil + } + return true, v.Problems +} + +// Why is a refusal in a reader's words: the module, that the indexer's judge refused its manifest, and each problem. +func (v ManifestVerdict) Why() string { + if !v.Refused() { + return "" + } + judge := "the indexer's judge" + if v.Judge != "" { + judge += " (" + v.Judge + ")" + } + why := fmt.Sprintf("%s refused the manifest of %s:", judge, v.Module) + for _, p := range v.Problems { + why += " " + p + ";" + } + return why[:len(why)-1] +} + +// ManifestRefused is the body of the indexer's event ManifestRefusedEvent: the module, the digest of its manifest, the +// judge and each problem, as its verdict holds them. +type ManifestRefused struct { + V int `json:"v"` + Module string `json:"module"` + Digest string `json:"digest"` + Judge string `json:"judge"` + Problems []string `json:"problems"` +} + +// RefusedOf is the event body saying v, the verdict on the manifest of digest. +func RefusedOf(digest string, v ManifestVerdict) ManifestRefused { + return ManifestRefused{V: Version, Module: v.Module, Digest: digest, Judge: v.Judge, Problems: v.Problems} +} diff --git a/go/delivery/verdicts_test.go b/go/delivery/verdicts_test.go new file mode 100644 index 0000000..0de3517 --- /dev/null +++ b/go/delivery/verdicts_test.go @@ -0,0 +1,65 @@ +package delivery + +import ( + "strings" + "testing" + "time" +) + +// A verdict on a manifest round-trips, refuses what it cannot be, and a reader takes a missing one as accepted (novox/hq +// ADR 0578 §1, §3); a modules value carries it. +func TestAManifestVerdictIsTheContractReadersTake(t *testing.T) { + at := time.Date(2026, 11, 1, 2, 0, 0, 0, time.UTC) + refused := ManifestVerdict{Module: "nats", State: VerdictRefused, Problems: []string{"a", "b"}, Judge: "src2:x", At: at} + data, err := EncodeManifestVerdict(refused) + if err != nil { + t.Fatal(err) + } + back, err := DecodeManifestVerdict(data) + if err != nil || !back.Same(refused) || back.Judge != "src2:x" || !back.At.Equal(at) { + t.Fatalf("round trip: %+v %v", back, err) + } + if !strings.Contains(back.Why(), "refused the manifest of nats: a; b") { + t.Fatalf("why: %q", back.Why()) + } + for name, bad := range map[string]ManifestVerdict{ + "no module": {State: VerdictAccepted, Judge: "j"}, + "no judge": {Module: "m", State: VerdictAccepted}, + "another state": {Module: "m", State: "maybe", Judge: "j"}, + "refused, no problem": {Module: "m", State: VerdictRefused, Judge: "j"}, + "accepted, a problem": {Module: "m", State: VerdictAccepted, Problems: []string{"x"}, Judge: "j"}, + } { + if _, err := EncodeManifestVerdict(bad); err == nil { + t.Errorf("%s: encoded", name) + } + } + if refusedTaken, _ := TakeManifestVerdict(nil); refusedTaken { + t.Fatal("a missing verdict was taken as a refusal") + } + if r, p := TakeManifestVerdict(&refused); !r || len(p) != 2 { + t.Fatalf("a refusal was not taken: %v %v", r, p) + } + if k, err := VerdictsKey(strings.Repeat("a", 64)); err != nil || k != strings.Repeat("a", 64) { + t.Fatalf("key: %q %v", k, err) + } + if _, err := VerdictsKey("nats"); err == nil { + t.Fatal("a key that is no digest") + } + m := Module{FP2: "src2:" + strings.Repeat("b", 64), Manifest: strings.Repeat("c", 64), Repository: "novox/x", + Commit: "c", Verdict: VerdictRefused, Problems: []string{"p"}} + data, err = EncodeModule(m) + if err != nil { + t.Fatal(err) + } + got, err := DecodeModule(data) + if err != nil { + t.Fatal(err) + } + if r, p := TakeManifestVerdict(got.VerdictOf("x")); !r || p[0] != "p" { + t.Fatalf("the module's verdict: %+v", got) + } + m.Problems = nil + if _, err := EncodeModule(m); err == nil { + t.Fatal("a module refused naming no problem was encoded") + } +} -- 2.54.0 From 7631a7d5de5001bfcbdfbb14918005f63d617a51 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 01:57:36 +0100 Subject: [PATCH 03/13] =?UTF-8?q?Hold=20a=20refused=20manifest=20in=20the?= =?UTF-8?q?=20bus=20reader=20instead=20of=20ending=20the=20whole=20read=20?= =?UTF-8?q?(ADR=200578=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1585, S2: one refused manifest silenced disk-load for 45 hours. frombus decodes leniently, takes the indexer's verdict, stands a refused module in as its stub and names it in Input.Refused; DataItemsHeld names it. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/compose/frombus/frombus.go | 110 +++++++++++++----- go/compose/frombus/frombus_test.go | 11 +- .../frombus/frombustest/frombustest_test.go | 23 +++- go/compose/frombus/held_rows_test.go | 43 +++++++ go/compose/input.go | 9 ++ 5 files changed, 156 insertions(+), 40 deletions(-) diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index a87ecf0..1bc385a 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -33,6 +33,7 @@ package frombus import ( "context" "encoding/json" + "errors" "fmt" "maps" "net/netip" @@ -110,8 +111,8 @@ type Mesh struct { OverlayCIDR string InternalSuffix string Authority string - // Predefined is the list of predefined keys mesh-config published, which a manifest's values falling back to a - // predefined key are judged against; nil reads each manifest as the indexer does (catalogue.ParseManifestForInputs). + // Predefined is the list of predefined keys mesh-config published. No longer read: a reader decodes a manifest and + // takes the indexer's verdict on it, and judges nothing (novox/hq ADR 0578 §3); kept so a caller that sets it builds. Predefined catalogue.PredefinedList } @@ -186,15 +187,41 @@ func InOwn(running []delivery.Composed, p delivery.Placement) []string { } // Input is the composition input for s, read through r. +// +// **A refused manifest never ends the read** (novox/hq ADR 0578 §3): each manifest is decoded leniently +// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (delivery.TakeManifestVerdict, a missing +// one accepted); a module whose manifest the indexer's judge refused, or whose bytes do not decode, stands in the +// catalogue as its stub (unusable), so what it provides and requires still shapes the rest, and is named with why in +// the input's Refused; a field set aside is named in its SetAside. Every other module of the read goes on. func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) { return input(ctx, r, mesh, s, nil, nil) } +// taken is a module's manifest as a reader takes it (novox/hq ADR 0578 §3): decoded leniently, with the indexer's verdict +// from its modules value. A refused one, or bytes that do not decode, stands as its stub (unusable) and why says so; a +// manifest not read past a field it set aside holds its name alone and is held the same. +func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest, why string) { + if v := m.VerdictOf(name); v != nil { + if refused, _ := delivery.TakeManifestVerdict(v); refused { + return unusable(name, raw), v.Why() + } + } + man, err := catalogue.Decode(raw) + switch { + case err != nil: + return unusable(name, raw), fmt.Sprintf("the manifest of %s does not decode: %v", name, err) + case man.ReadNoFurther(): + return unusable(name, raw), fmt.Sprintf("the manifest of %s could not be read past a field this reader does not "+ + "know: %s", name, man.SetAside()) + } + return man, "" +} + // input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in, or whose -// manifest the mesh refuses, is handed to refuse and composed as a stub (compose.Stub, kept for that node alone) -// rather than ending the read, so a node's answer names every module it cannot compose (issue 1126, issue 774); a -// module placed elsewhere whose manifest the mesh refuses is read for its offer alone (unusable). A manifest the bus does -// not answer for still ends the read: that is the bus's, not the module's. +// manifest the indexer refused, is also handed to refuse and composed as a stub (compose.Stub, kept for that node alone), +// so a node's answer names every module it cannot compose (issue 1126, issue 774); a module placed elsewhere whose +// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the +// read: that is the bus's, not the module's. func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { placements, err := r.Placements(ctx) if err != nil { @@ -258,7 +285,17 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module if err != nil { return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err) } - return parse(raw) + man, err := parse(raw) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err) + } + return man, nil + } + named := func(field *map[string]string, module, why string) { + if *field == nil { + *field = map[string]string{} + } + (*field)[module] = why } ownPlaced := map[string]bool{} for _, pm := range placements[s.Node].Modules { @@ -274,19 +311,18 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module // Not read is no refusal of the module: a bus that does not answer is waited out by the caller. return compose.Input{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err) } - man, err := parse(raw) - if err != nil { - if refuse == nil { - return compose.Input{}, err - } - // One module's manifest the mesh refuses refuses that module alone, by the rule a build that cannot be read - // follows (issue 774: power's bar item refused every node's composition, 2026-10-30). It stands in as its - // unjudged stub, so what it provides and requires still shapes the rest; it is held on the composing node - // where it is placed there, and elsewhere only its offer is read. - man = unusable(name, raw) - if ownPlaced[name] { - refuse(name, err) + // One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read + // follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused + // manifest silenced disk-load for 45 hours). It stands in as its stub, so what it provides and requires still + // shapes the rest; it is held on the composing node where it is placed there, and elsewhere only its offer is read. + man, why := taken(name, raw, m) + if why != "" { + named(&in.Refused, name, why) + if refuse != nil && ownPlaced[name] { + refuse(name, errors.New(why)) } + } else if aside := man.SetAside(); aside != "" { + named(&in.SetAside, name, aside) } in.Catalogue[name] = man } @@ -595,14 +631,9 @@ func unusable(module string, raw []byte) catalogue.Manifest { return compose.Stub(m) } -// parser reads a manifest as the indexer does, against the mesh's predefined keys when given. -func parser(mesh Mesh) func([]byte) (catalogue.Manifest, error) { - return func(raw []byte) (catalogue.Manifest, error) { - if mesh.Predefined != nil { - return catalogue.ParseManifestWith(raw, mesh.Predefined) - } - return catalogue.ParseManifestForInputs(raw) - } +// parser reads a manifest as every reader does (novox/hq ADR 0578 §3): decoded leniently, judged by nothing here. +func parser(Mesh) func([]byte) (catalogue.Manifest, error) { + return catalogue.Decode } // ranOf is what a module last offered the others, read when it is held (composeEach, issue 1960): its manifest at the @@ -723,6 +754,9 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del // it runs (Scope.InOwn). continue } + if _, refused := in.Refused[pm.Module]; refused { + continue // held as its stub: no build of a refused manifest is taken in (ADR 0578 §3) + } if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) { continue // nothing is built of it: its manifest is what it composes from } @@ -1007,11 +1041,25 @@ func membershipOf(own delivery.Composed) string { } // DataItems is every data item the modules placed on scope's node declare, placed there, composed from the bus as Plans -// composes (novox/hq ADR 0571 clause 3): disk-load measures these. +// composes (novox/hq ADR 0571 clause 3): disk-load measures these. A module whose manifest the indexer refused is held +// and its items left out; DataItemsHeld names it, and a reader that must say what it leaves out reads that. func DataItems(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string][]catalogue.PlacedDataItem, []string, error) { - in, err := Input(ctx, r, mesh, s) + items, unplaced, _, err := DataItemsHeld(ctx, r, mesh, s) + return items, unplaced, err +} + +// DataItemsHeld is DataItems and, by module, why each module placed on scope's node is held: its manifest refused by +// the indexer's judge or not decoded (compose.Input.Refused). One refused manifest never ends the read (novox/hq ADR +// 0578 §3: disk-load measured nothing for 45 hours when one did). +func DataItemsHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (items map[string][]catalogue.PlacedDataItem, unplaced []string, held map[string]string, err error) { + held = map[string]string{} + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil) if err != nil { - return nil, nil, err + return nil, nil, held, err } - return compose.DataItems(in, s.Node) + items, unplaced, err = compose.DataItems(in, s.Node) + for m := range held { + delete(items, m) + } + return items, unplaced, held, err } diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index acf7718..9ea9fdc 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -142,6 +142,7 @@ func TestInput(t *testing.T) { t.Run("no store reached says why", noStoreReachedSaysWhy) // Issue 774: a node refused whole says what it held on the way. t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld) + t.Run("a refused manifest never ends the read", aRefusedManifestNeverEndsTheRead) t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes) t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild) t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads) @@ -194,11 +195,11 @@ func TestInput(t *testing.T) { if err := json.Unmarshal(raw, &private); err != nil { t.Fatal(err) } - parsed, err := catalogue.ParseManifestForInputs(xterm) + parsed, err := catalogue.Decode(xterm) if err != nil { t.Fatal(err) } - parsedMedia, err := catalogue.ParseManifestForInputs(media) + parsedMedia, err := catalogue.Decode(media) if err != nil { t.Fatal(err) } @@ -549,7 +550,7 @@ func TestInput(t *testing.T) { toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64) kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old", Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}} - parsedOld, err := catalogue.ParseManifestForInputs(oldXterm) + parsedOld, err := catalogue.Decode(oldXterm) if err != nil { t.Fatal(err) } @@ -672,7 +673,7 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - clientParsed, err := catalogue.ParseManifestForInputs(clientRaw) + clientParsed, err := catalogue.Decode(clientRaw) if err != nil { t.Fatal(err) } @@ -696,7 +697,7 @@ func TestInput(t *testing.T) { } // Kept as its bytes and read again, as the controller's store holds a build's manifest. raw, _ := json.Marshal(resolved) - if storeIn.Catalogue[module], err = catalogue.ParseManifestForInputs(raw); err != nil { + if storeIn.Catalogue[module], err = catalogue.Decode(raw); err != nil { t.Fatal(err) } } diff --git a/go/compose/frombus/frombustest/frombustest_test.go b/go/compose/frombus/frombustest/frombustest_test.go index 24e7b87..5d6d751 100644 --- a/go/compose/frombus/frombustest/frombustest_test.go +++ b/go/compose/frombus/frombustest/frombustest_test.go @@ -26,16 +26,21 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { why string // words backup's hold says // elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held. elsewhere bool + // refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds + // backup too, and never ends the read. + refused string }{ {name: "every module composes"}, {name: "a built manifest that cannot be used", bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, - built: `{"module":"backup","version":"1","requires":["amqp"]}`, why: "backup"}, + built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup"}, {name: "a plan that refuses", bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` + `"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"}, - {name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`}, - {name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true}, + {name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`, + refused: `"shows" is none of them`}, + {name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true, + refused: `"shows" is none of them`}, } { b, err := Provisioned() if err != nil { @@ -50,13 +55,21 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { digest := delivery.ManifestDigest([]byte(row.bad)) b.Manifests[digest] = []byte(row.bad) b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest} + if row.refused != "" { + b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused, + Problems: []string{row.refused}} + } if row.built != "" { builtDigest := delivery.ManifestDigest([]byte(row.built)) b.Manifests[builtDigest] = []byte(row.built) b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest, Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}} } - if _, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}); err == nil && !row.elsewhere { + plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + switch { + case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")): + t.Errorf("%s: Plans did not hold the refused manifest alone: %v %v", row.name, plans, err) + case row.refused == "" && err == nil && !row.elsewhere: t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name) } } @@ -101,6 +114,8 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { } } +func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok } + // unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item // with attributes its bar places by none of. const unusableItem = `{"module":"backup","version":"1","moved":true,` + diff --git a/go/compose/frombus/held_rows_test.go b/go/compose/frombus/held_rows_test.go index 87959d1..acb0907 100644 --- a/go/compose/frombus/held_rows_test.go +++ b/go/compose/frombus/held_rows_test.go @@ -37,6 +37,7 @@ func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) { refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}` needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}` b, fps := aNode(t, refused, needs) + refuse(b, "power", `"shows" is none of them`) _, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps) if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) || !strings.Contains(err.Error(), `"shows" is none of them`) { @@ -44,6 +45,48 @@ func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) { } } +// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1). +func refuse(b *bus, module string, problems ...string) { + m := b.modules[module] + m.Verdict, m.Problems = delivery.VerdictRefused, problems + b.modules[module] = m +} + +// ADR 0578 §3: the bus reader never ends a whole read on a refused manifest. Input, Plans and DataItems compose the rest +// and hold the refused module, named with the indexer's words in Refused; a field the decoder does not know is set aside +// and named, and its module composed. +func aRefusedManifestNeverEndsTheRead(t *testing.T) { + ok := `{"module":"lamp","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/lamp","mode":"0700"}],` + + `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}` + bad := `{"module":"power","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/power","mode":"0700"}],` + + `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}` + later := `{"module":"later","version":"1","a-field-from-later":1}` + b, _ := aNode(t, ok, bad, later) + refuse(b, "power", "removes a stream it may not") + ctx := context.Background() + in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"}) + if err != nil { + t.Fatalf("one refused manifest ended the read: %v", err) + } + if why := in.Refused["power"]; !strings.Contains(why, "refused the manifest of power: removes a stream it may not") || + len(in.Refused) != 1 { + t.Fatalf("refused: %v", in.Refused) + } + if aside := in.SetAside["later"]; !strings.Contains(aside, "a-field-from-later") { + t.Fatalf("set aside: %v", in.SetAside) + } + if !in.Catalogue["power"].Stubbed || in.Catalogue["lamp"].Stubbed || in.Catalogue["later"].Module != "later" { + t.Fatalf("the catalogue: %+v", in.Catalogue) + } + if _, err := Plans(ctx, b, Mesh{}, Scope{Node: "n1"}); err != nil { + t.Fatalf("plans: %v", err) + } + items, _, held, err := DataItemsHeld(ctx, b, Mesh{}, Scope{Node: "n1"}) + if err != nil || len(items["lamp"]) != 1 || items["power"] != nil || !strings.Contains(held["power"], "removes a stream") { + t.Fatalf("data items %v held %v: %v", items, held, err) + } +} + // Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub // composes, since its stub always fails otherwise. func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) { diff --git a/go/compose/input.go b/go/compose/input.go index 4e09f53..72aecf7 100644 --- a/go/compose/input.go +++ b/go/compose/input.go @@ -101,6 +101,15 @@ type Input struct { // declaration carries to a node-engine that reads them. CurrentBuilds map[string]string BuildIDs map[string]map[string]string + + // Refused is each module whose manifest the indexer's judge refused, or whose bytes do not decode, by module, with why + // (novox/hq ADR 0578 §3, §5): its verdict, carried in the plain input. Such a module stands in Catalogue as its stub + // (compose.Stub), so it is held: nothing of its own is composed, while what it provides and requires still shapes the + // rest. A module not named is accepted, a missing verdict included. + Refused map[string]string `json:",omitempty"` + // SetAside is each module whose manifest has a field the reader's decoder does not know, by module, with what was set + // aside (catalogue.Manifest.SetAside): the rest of it is composed, and the reader names it (ADR 0578 §6). + SetAside map[string]string `json:",omitempty"` } // Issued is one pair credential made for a consumer of a provider (inv.Secret): ForProvider is its copy sealed to the -- 2.54.0 From 7b09de1e5502231fcd7e41a61086b44f5d7ad545 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 01:57:36 +0100 Subject: [PATCH 04/13] =?UTF-8?q?Let=20the=20composer=20take=20each=20mani?= =?UTF-8?q?fest's=20verdict=20from=20its=20input=20instead=20of=20judging?= =?UTF-8?q?=20(ADR=200578=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1585, S11 (mesh-sdk half): Case.Verdicts carries the indexer's verdicts; a refused module is held as its stub and named; an unknown field is set aside and named, never refused. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/cmd/mesh-compose/main.go | 11 +---- go/cmd/mesh-compose/main_test.go | 49 +++++------------------ go/composer/composer.go | 69 ++++++++++++++++++-------------- go/composer/composer_test.go | 29 ++++++++------ 4 files changed, 67 insertions(+), 91 deletions(-) diff --git a/go/cmd/mesh-compose/main.go b/go/cmd/mesh-compose/main.go index 3168bde..9c38a8f 100644 --- a/go/cmd/mesh-compose/main.go +++ b/go/cmd/mesh-compose/main.go @@ -28,7 +28,6 @@ import ( "sort" "strings" - "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/composer" ) @@ -97,15 +96,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { req.Cases[0].Manifests[name] = m } } - // The catalogue's list of predefined keys is the one its manifests' fallbacks are judged against (ADR 0348 §7). - if req.Predefined == nil { - list, err := catalogue.ReadPredefined(*catalogueDir) - if err != nil { - fmt.Fprintln(stderr, "mesh-compose:", err) - return 1 - } - req.Predefined = list - } + // No list of predefined keys is read: the composer judges no manifest (novox/hq ADR 0578 §5). } req.Machines = append(req.Machines, machines...) if *depth != "" { diff --git a/go/cmd/mesh-compose/main_test.go b/go/cmd/mesh-compose/main_test.go index c68ff93..df60e35 100644 --- a/go/cmd/mesh-compose/main_test.go +++ b/go/cmd/mesh-compose/main_test.go @@ -16,6 +16,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/compose" "git.novox.be/novox/mesh-sdk/go/composer" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/internal/beside" ) @@ -77,7 +78,9 @@ func encode(t *testing.T, r composer.Request) []byte { // refused. func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) { r := request(t, composer.DepthResolve) - r.Cases[0].Manifests["broken"] = composer.Manifest{Raw: json.RawMessage(`{"module":"broken","version":"1","shares":["x"]}`)} + r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)} + r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)} + r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}} code, v, stderr := runJSON(t, nil, encode(t, r)) if code != 0 { t.Fatalf("exit %d: %s", code, stderr) @@ -86,8 +89,13 @@ func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) { t.Fatalf("verdict = %+v", v) } c := v.Cases[0] - if got := c.Manifests["broken"]; got.Accepted || !strings.Contains(got.Refused, `"shares"`) { - t.Errorf("the manifest with an unknown field reads %+v, want refused naming the field", got) + // ADR 0578 §5, §6: the composer judges nothing; it sets an unknown field aside and names it, and holds a manifest + // its verdict refuses, named in the indexer's words. + if got := c.Manifests["later"]; !got.Accepted || !strings.Contains(got.SetAside, `"shares"`) { + t.Errorf("the manifest with an unknown field reads %+v, want accepted naming the field set aside", got) + } + if got := c.Manifests["refused"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of refused: it is wrong") { + t.Errorf("the refused manifest reads %+v, want refused in the verdict's words", got) } if got := c.Manifests["nats"]; !got.Accepted { t.Errorf("nats reads %+v, want accepted", got) @@ -310,38 +318,3 @@ func TestTheCommandRunsWithNoNetwork(t *testing.T) { sort.Strings(names) t.Logf("composed with no network: %v", names) } - -// -catalogue judges a fallback to a predefined key against the predefined.json at the checkout's root (ADR 0348 §7): -// xterm (mesh-catalog at 835bf22b) falls back to "font-size", accepted where the root lists it and refused where the -// root's list does not. -func TestACatalogueCheckoutJudgesFallbacksAgainstItsPredefinedKeys(t *testing.T) { - raw, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json") - if err != nil { - t.Fatal(err) - } - for _, c := range []struct { - list string - want bool - }{ - {`{"font-size": {"type": "number", "description": "the desktop's font size"}}`, true}, - {`{"font-family": {"type": "string", "description": "the desktop's font"}}`, false}, - } { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "modules", "xterm"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "modules", "xterm", "module.json"), raw, 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, catalogue.PredefinedFile), []byte(c.list), 0o600); err != nil { - t.Fatal(err) - } - code, v, stderr := runJSON(t, []string{"-catalogue", dir}, nil) - if code != 0 { - t.Fatalf("exit %d: %s", code, stderr) - } - if got := v.Cases[0].Manifests["xterm"]; got.Accepted != c.want { - t.Errorf("predefined.json %s: xterm %+v, want accepted %v", c.list, got, c.want) - } - } -} diff --git a/go/composer/composer.go b/go/composer/composer.go index 216c8fd..164a096 100644 --- a/go/composer/composer.go +++ b/go/composer/composer.go @@ -1,4 +1,4 @@ -// Package composer is mesh-sdk's composition and validator as one function over a plain request (novox/hq ADR 0482 +// Package composer is mesh-sdk's composition as one function over a plain request (novox/hq ADR 0482 // clause 4, issue 1621): what the command mesh-compose runs, so a program that links any one mesh-sdk can have the // composition of another commit judged by building that commit's command and reading its verdict. // @@ -57,11 +57,9 @@ type Request struct { HoldingsFromClaims bool `json:"holdings_from_claims,omitempty"` // Declarations asks each composed machine's declared paths (DepthCompose only). Declarations bool `json:"declarations,omitempty"` - // Predefined is the list of predefined keys at the catalogue's root (predefined.json, novox/hq ADR 0348 §7) that - // every config value falling back to a key is judged against, when the asker has it (mesh-compose -catalogue - // reads it). Without it a fallback is read as catalogue.ParseManifestForInputs reads it: against the list the mesh - // published when this process holds one, else each key as its value declares it, for the merge gate judges the - // fallback in the catalogue and a manifest that only carries one is not refused here. + // Predefined is the list of predefined keys at the catalogue's root (predefined.json, novox/hq ADR 0348 §7). No + // longer read: the composer judges no manifest (novox/hq ADR 0578 §5), the indexer's judge does, and its verdicts + // come in each case's Verdicts. Kept so an asker that still sends it is read. Predefined catalogue.PredefinedList `json:"predefined,omitempty"` } @@ -70,11 +68,16 @@ type Request struct { // Placements are, by node, the placement the case composes that node with in place of what Input says of it: a change's // own placement files, so a check composes a catalogue pull request's placement (issue 1126, frombus.Placed); a null // placement leaves nothing placed on the node. A case without them composes Input's. +// +// Verdicts are, by module, the indexer's judge's verdict on its manifest in Manifests (novox/hq ADR 0578 §5), put in by +// the assigner or the checker that runs the composer: a refused one is held, composed as its stub so what it offers +// still shapes the rest, and named in the case's verdict. A module without one is accepted (delivery.TakeManifestVerdict). type Case struct { - Name string `json:"name"` - Manifests map[string]Manifest `json:"manifests"` - Kept map[string]map[string]Manifest `json:"kept,omitempty"` - Placements map[string]*delivery.Placement `json:"placements,omitempty"` + Name string `json:"name"` + Manifests map[string]Manifest `json:"manifests"` + Verdicts map[string]delivery.ManifestVerdict `json:"verdicts,omitempty"` + Kept map[string]map[string]Manifest `json:"kept,omitempty"` + Placements map[string]*delivery.Placement `json:"placements,omitempty"` } // Manifest is a module's manifest as its repository holds it (Raw, the bytes), or the digest the delivery indexer keeps @@ -124,10 +127,13 @@ type CaseVerdict struct { Machines map[string]Machine `json:"machines,omitempty"` } -// ManifestRead is one manifest as this mesh-sdk reads it: accepted, or refused with the reader's words. +// ManifestRead is one manifest as this mesh-sdk reads it: accepted, or refused with why: the indexer's verdict +// (Case.Verdicts) or bytes that do not decode. A field the decoder does not know is set aside, named in SetAside, and +// the manifest still accepted (novox/hq ADR 0578 §6). type ManifestRead struct { Accepted bool `json:"accepted"` Refused string `json:"refused,omitempty"` + SetAside string `json:"set_aside,omitempty"` } // Machine is one machine composed: whether it composes (resolves, at DepthResolve), why not, the node-engine @@ -222,31 +228,34 @@ func Run(r Request) (Verdict, error) { return v, nil } -// readManifest reads one manifest as this run reads every one: against the request's list of predefined keys when it -// has one, else as the indexer and the builder read it (Request.Predefined). -func readManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.Manifest, error) { - if predefined != nil { - return catalogue.ParseManifestWith(raw, predefined) - } - return catalogue.ParseManifestForInputs(raw) -} - -func parse(m map[string]Manifest, reads map[string]ManifestRead, predefined catalogue.PredefinedList) (map[string]catalogue.Manifest, error) { +// parse decodes every manifest leniently and takes its verdict (novox/hq ADR 0578 §3, §5): the composer judges none. A +// manifest refused by its verdict is held, composed as its stub without its items (as the bus reader holds one); bytes +// that do not decode are left out. Both are named in reads. +func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[string]delivery.ManifestVerdict) (map[string]catalogue.Manifest, error) { out := map[string]catalogue.Manifest{} for name, man := range m { if len(man.Raw) == 0 { return nil, fmt.Errorf("%w: %s (digest %s)", ErrUnfilled, name, man.Digest) } - parsed, err := readManifest(man.Raw, predefined) - if reads != nil { - if err != nil { - reads[name] = ManifestRead{Refused: err.Error()} - } else { - reads[name] = ManifestRead{Accepted: true} + decoded, err := catalogue.Decode(man.Raw) + read := ManifestRead{Accepted: err == nil, SetAside: decoded.SetAside()} + if err != nil { + read.Refused = fmt.Sprintf("the manifest of %s does not decode: %v", name, err) + } else if v, ok := verdicts[name]; ok { + if refused, _ := delivery.TakeManifestVerdict(&v); refused { + if v.Module == "" { + v.Module = name + } + read = ManifestRead{Refused: v.Why()} + decoded.Items = nil + decoded = compose.Stub(decoded) } } + if reads != nil { + reads[name] = read + } if err == nil { - out[name] = parsed + out[name] = decoded } } return out, nil @@ -254,7 +263,7 @@ func parse(m map[string]Manifest, reads map[string]ManifestRead, predefined cata func runCase(r Request, c Case, depth string) (CaseVerdict, error) { cv := CaseVerdict{Name: c.Name, Manifests: map[string]ManifestRead{}} - shelf, err := parse(c.Manifests, cv.Manifests, r.Predefined) + shelf, err := parse(c.Manifests, cv.Manifests, c.Verdicts) if err != nil { return CaseVerdict{}, err } @@ -268,7 +277,7 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { for i := range in.Nodes { in.Nodes[i].Kept = nil if kept := c.Kept[in.Nodes[i].Name]; len(kept) > 0 { - k, err := parse(kept, nil, r.Predefined) + k, err := parse(kept, nil, nil) if err != nil { return CaseVerdict{}, err } diff --git a/go/composer/composer_test.go b/go/composer/composer_test.go index 6968c2c..9570894 100644 --- a/go/composer/composer_test.go +++ b/go/composer/composer_test.go @@ -120,20 +120,17 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { } } -// A manifest whose config value falls back to a predefined key (novox/hq ADR 0348 §7) is read, not refused for the -// list of predefined keys at the catalogue's root that a run is never handed with its manifests: the merge gate judges -// the fallback in the catalogue. xterm's module.json from mesh-catalog at 835bf22b falls back to "font-size"; a group -// touching xterm or pacman failed its composition on this (group check 18df81380db3). A list in the request is the one -// judged against, so a fallback it does not name is still refused. -func TestAManifestFallingBackToAPredefinedKeyIsRead(t *testing.T) { +// The composer judges no manifest (novox/hq ADR 0578 §5): xterm's module.json from mesh-catalog at 835bf22b, whose +// config value falls back to "font-size", is read whatever list of predefined keys a request carries (a group touching +// xterm failed its composition on this, group check 18df81380db3); a module whose verdict in the input refuses it is +// held, composed as its stub, and named in the verdict's words. +func TestTheComposerTakesTheVerdictsOfItsInput(t *testing.T) { raw, err := os.ReadFile("../catalogue/testdata/catalogue-835bf22b-xterm.module.json") if err != nil { t.Fatal(err) } - if !strings.Contains(string(raw), `"predefined"`) { - t.Fatal("the fixture names no predefined key") - } - r := Request{V: Version, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"xterm": {Raw: raw}}}}} + r := Request{V: Version, Predefined: catalogue.PredefinedList{"something-else": {Type: "string", Description: "x"}}, + Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"xterm": {Raw: raw}}}}} v, err := Run(r) if err != nil { t.Fatal(err) @@ -141,11 +138,17 @@ func TestAManifestFallingBackToAPredefinedKeyIsRead(t *testing.T) { if got := v.Cases[0].Manifests["xterm"]; !got.Accepted { t.Fatalf("xterm: %+v, want accepted", got) } - r.Predefined = catalogue.PredefinedList{"something-else": {Type: "string", Description: "x"}} + r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"xterm": {Module: "xterm", State: delivery.VerdictRefused, + Problems: []string{"falls back to a key not listed"}, Judge: "src2:j"}} + shelf, err := parse(r.Cases[0].Manifests, map[string]ManifestRead{}, r.Cases[0].Verdicts) + if err != nil || !shelf["xterm"].Stubbed { + t.Fatalf("the refused module is not held as its stub: %+v %v", shelf["xterm"], err) + } if v, err = Run(r); err != nil { t.Fatal(err) } - if got := v.Cases[0].Manifests["xterm"]; got.Accepted || !strings.Contains(got.Refused, "not on the list of predefined keys") { - t.Fatalf("xterm judged against a list without its key: %+v, want refused naming the list", got) + if got := v.Cases[0].Manifests["xterm"]; got.Accepted || + !strings.Contains(got.Refused, "the indexer's judge (src2:j) refused the manifest of xterm: falls back to a key not listed") { + t.Fatalf("xterm refused by its verdict: %+v, want refused in the verdict's words", got) } } -- 2.54.0 From 9904a03b51dd326f2e249006226e1ad5d98259c8 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 02:18:56 +0100 Subject: [PATCH 05/13] =?UTF-8?q?Let=20the=20merge=20preview=20and=20the?= =?UTF-8?q?=20provided=20manifest=20decode=20instead=20of=20judging=20(ADR?= =?UTF-8?q?=200578=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1585, S12 (mesh-sdk half): buildstep's named paths and delivery.ProvidedManifest read with catalogue.Decode. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buildstep/trees.go | 9 +++++---- go/delivery/provided.go | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/go/buildstep/trees.go b/go/buildstep/trees.go index 65a775e..b63065f 100644 --- a/go/buildstep/trees.go +++ b/go/buildstep/trees.go @@ -75,11 +75,12 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co } // narrow sets mt's Paths and its named Tree when the module's manifest names paths on every artifact (novox/hq ADR 0499 -// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview reads the manifest here, as -// the indexer does, with catalogue.ParseManifestForInputs (issue 1751), and no other build step reads paths from one (ADR 0495 clause 1). A manifest that does not parse -// is not narrowed: the indexer gives it none. A named path absent at commit fails, naming the module and the path. +// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview decodes the manifest here +// (catalogue.Decode: a build step judges no manifest, novox/hq ADR 0578 §3), and no other build step reads paths from one +// (ADR 0495 clause 1). A manifest that does not decode is not narrowed: the indexer gives it none. A named path absent at +// commit fails, naming the module and the path. func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, mdir string, raw []byte, mt *ModuleTree) error { - m, err := catalogue.ParseManifestForInputs(raw) + m, err := catalogue.Decode(raw) if err != nil { return nil } diff --git a/go/delivery/provided.go b/go/delivery/provided.go index 712453e..bcb58e6 100644 --- a/go/delivery/provided.go +++ b/go/delivery/provided.go @@ -29,9 +29,10 @@ func ProvidedManifest(module string) (catalogue.Manifest, []byte, error) { if err != nil { return catalogue.Manifest{}, nil, err } - m, err := catalogue.ParseManifestForInputs(raw) + // Decoded as every reader decodes a manifest (novox/hq ADR 0578 §3); its judgement is the indexer's. + m, err := catalogue.Decode(raw) if err != nil { - return catalogue.Manifest{}, raw, fmt.Errorf("delivery: the manifest mesh-sdk provides for %s is refused by its own reader: %w", module, err) + return catalogue.Manifest{}, raw, fmt.Errorf("delivery: the manifest mesh-sdk provides for %s does not decode: %w", module, err) } return m, raw, nil } -- 2.54.0 From 7cab079e2ec3c017c14a29cfdf52942f2e2c3398 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 02:20:31 +0100 Subject: [PATCH 06/13] Merge the set-aside field's three tests into one, so the judge work adds no test to the budget Issue 1585: mesh-sdk and mesh-delivery are both over 500 unit tests; ADR 0578's tests replace, never add. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/setting_defaults_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/go/catalogue/setting_defaults_test.go b/go/catalogue/setting_defaults_test.go index 1bdf7a9..a1f013d 100644 --- a/go/catalogue/setting_defaults_test.go +++ b/go/catalogue/setting_defaults_test.go @@ -302,6 +302,8 @@ func TestADefaultFillsAContributionAndAServedFactButNotALiteral(t *testing.T) { // aside and named, and its module is composed, never left out (novox/hq ADR 0578 §6, which changes ADR 0262); the judge // refuses it. One case per block whose decoder wraps the decoder's words in its own. func TestAStoredManifestWithAnUnknownKeyIsSetAsideAndTheJudgeRefusesIt(t *testing.T) { + t.Run("it still provides what it provides", aModuleWithAFieldSetAsideStillProvidesWhatItProvides) + t.Run("it is composed whole", aModuleWithAFieldSetAsideIsComposedWhole) for where, raw := range map[string]string{ "the top": `{"module": "later", "version": "2", "a-field-from-later": {"x": 1}}`, "a state": `{"module": "later", "version": "2", "state": [{"name": "s", "a-field-from-later": 1}]}`, @@ -345,7 +347,7 @@ func TestAStoredManifestWithAnUnknownKeyIsSetAsideAndTheJudgeRefusesIt(t *testin // A module left out for a key this controller does not know, inside an entry, is read past that key // alone: it still provides what it provides, its other entries are whole, and a key of the same name // that another entry knows is kept (novox/hq ADR 0262). -func TestALeftOutModuleStillProvidesWhatItProvides(t *testing.T) { +func aModuleWithAFieldSetAsideStillProvidesWhatItProvides(t *testing.T) { var m Manifest raw := `{"module": "later", "version": "2", "provides": [{"name": "db", "scope": "mesh"}, {"name": "cache", "a-field-from-later": 1}], @@ -376,7 +378,7 @@ func TestALeftOutModuleStillProvidesWhatItProvides(t *testing.T) { // A module with a field set aside is composed whole: its files are declared and its data backed up (novox/hq ADR 0578 // §6; before, ADR 0262 left it out and backed up its data alone). -func TestAModuleWithAFieldSetAsideIsComposedWhole(t *testing.T) { +func aModuleWithAFieldSetAsideIsComposedWhole(t *testing.T) { var later Manifest if err := json.Unmarshal([]byte(`{"module": "later", "version": "2", "a-field-from-later": 1, "resources": [{"id": "d", "type": "directory", "mode": "0700"}, {"id": "rc", "type": "file", "path": "/etc/later.conf", "content": "x\n"}], -- 2.54.0 From 25a2cecd989cc45cf84590d6d144951dadf855c9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 02:33:19 +0100 Subject: [PATCH 07/13] =?UTF-8?q?Let=20the=20tree=20readers=20decode,=20gi?= =?UTF-8?q?ve=20the=20judge=20the=20manifest's=20data=20it=20reads,=20and?= =?UTF-8?q?=20stop=20judging=20outside=20the=20catalogue=20(ADR=200578=20?= =?UTF-8?q?=C2=A73,=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1585, S6-S8: ReadTreeManifestWritten and WithTemplatesFrom decode (the judge in mesh-delivery judges what they answer); Manifest.Predefined, SetPredefined and FormsWritten expose what the moved judge reads; buscompose names its removals without the catalogue's allow-list; tests outside the catalogue decode, two tree-judging tests moved. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/announce/announce.go | 2 +- go/buildstep/trees_named_test.go | 13 ++++------- go/buscompose/health_grants.go | 5 ++-- go/buscompose/modstreams.go | 2 +- go/buscompose/users.go | 2 +- go/catalogue/decode.go | 11 +++++++++ go/catalogue/handed_consumers_test.go | 31 ------------------------- go/catalogue/predefined.go | 13 +++++++---- go/catalogue/setting_in_owner_test.go | 32 -------------------------- go/catalogue/template.go | 4 +++- go/cmd/mesh-compose/main_test.go | 2 +- go/compose/frombus/answer_rows_test.go | 2 +- go/composer/composer_test.go | 2 +- go/overlay/seat_test.go | 5 ++-- go/provisions/provisions_test.go | 4 ---- 15 files changed, 40 insertions(+), 90 deletions(-) diff --git a/go/announce/announce.go b/go/announce/announce.go index c4cbd2b..8cf8867 100644 --- a/go/announce/announce.go +++ b/go/announce/announce.go @@ -33,7 +33,7 @@ const KeptBucket = "mesh-controller_states-announced" var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) // Key is module's key in KeptBucket: the module itself, which must be one plain name, since a module's name is part of -// its buckets' names (catalogue.StateProblems). +// its buckets' names (the indexer's judge, novox/hq ADR 0578 §1). func Key(module string) (string, error) { if !name.MatchString(module) { return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module) diff --git a/go/buildstep/trees_named_test.go b/go/buildstep/trees_named_test.go index 3fdf0ea..d9438e4 100644 --- a/go/buildstep/trees_named_test.go +++ b/go/buildstep/trees_named_test.go @@ -75,20 +75,17 @@ func TestModuleTreesFailsNamingANamedPathAbsentAtTheCommit(t *testing.T) { } } -// A manifest whose config value falls back to a predefined key is narrowed here as the indexer narrows it: both read it -// with catalogue.ParseManifestForInputs (issue 1751), so a build module holding no published list computes the same -// tree (ADR 0499 clause 2). +// A manifest whose config value falls back to a predefined key is narrowed here: the merge preview decodes it and judges +// nothing (novox/hq ADR 0578 §3), so a build module holding no published list computes the tree the indexer does (ADR +// 0499 clause 2; issue 1751). func TestModuleTreesNarrowsAManifestFallingBackToAPredefinedKey(t *testing.T) { was := catalogue.ReplacePublished(nil) // the build modules' case: no published list defer catalogue.ReplacePublished(was) root := t.TempDir() raw := `{"module":"xterm","version":"1","config":{"size":{"type":"number","description":"The text size.","predefined":"font-size","default":12,"runtime":true}},` + `"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src"]}]}}` - if _, err := catalogue.ParseManifest([]byte(raw)); err == nil { - t.Fatal("ParseManifest reads the manifest with no list; the test proves nothing") - } - if _, err := catalogue.ParseManifestForInputs([]byte(raw)); err != nil { - t.Fatalf("the indexer's read refuses the manifest: %v", err) + if _, err := catalogue.Decode([]byte(raw)); err != nil { + t.Fatalf("the preview's read refuses the manifest: %v", err) } writeTreeFile(t, root, "module.json", raw) run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { diff --git a/go/buscompose/health_grants.go b/go/buscompose/health_grants.go index f997e60..7225b6e 100644 --- a/go/buscompose/health_grants.go +++ b/go/buscompose/health_grants.go @@ -42,7 +42,7 @@ var ( // write's step, clause 5); the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal // would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; the ASSIGNMENTS stream read and deleted whole (the lease // write, research 072 row 33). A -// removal outside the set is refused, as the catalogue refuses it (catalogue.BusRemovals). +// removal outside the set is refused, as the indexer's judge refuses it in a manifest (novox/hq ADR 0578 §1). func BusRemovalGrants(removes []string) ([]string, error) { var out []string for _, r := range removes { @@ -56,7 +56,8 @@ func BusRemovalGrants(removes []string) ([]string, error) { out = append(out, "$JS.API.STREAM.INFO."+AssignmentsStream, "$JS.API.STREAM.DELETE."+AssignmentsStream) default: return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4)", r, - strings.Join(catalogue.BusRemovals, ", ")) + strings.Join([]string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs, + catalogue.RemovesAssignmentsStream}, ", ")) } } return out, nil diff --git a/go/buscompose/modstreams.go b/go/buscompose/modstreams.go index d270738..956d2ed 100644 --- a/go/buscompose/modstreams.go +++ b/go/buscompose/modstreams.go @@ -108,7 +108,7 @@ func (s ModuleStream) Stream() Stream { // QueueOwner is the one process of a module that is the durable consumer of one of its own work-queue // streams (novox/hq ADR 0436 §1, §2, §6): named in that process's bus.queues, and checked at registration -// to be the only one (catalogue.WorkQueueStreamProblems). +// to be the only one (the indexer's judge, novox/hq ADR 0578 §1). type QueueOwner struct { // Process is the process resource's id — its bus account's name, with the node and module (novox/hq ADR // 0436 §2). diff --git a/go/buscompose/users.go b/go/buscompose/users.go index aaf966e..31fdfec 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -108,7 +108,7 @@ type ProcessPrincipal struct { Emits []string // Queues are the module's own work-queue streams this process is the one consumer of. Queues []QueueOwner - // Removes are what its account may remove from the bus, as its bus declares them (catalogue.BusRemovals; novox/hq + // Removes are what its account may remove from the bus, as its bus declares them (the indexer's judge refuses any other; novox/hq // ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants. Removes []string // State is the module's own state, by local name, this process reads and writes. diff --git a/go/catalogue/decode.go b/go/catalogue/decode.go index 09008b6..b362814 100644 --- a/go/catalogue/decode.go +++ b/go/catalogue/decode.go @@ -29,3 +29,14 @@ func (m Manifest) ReadNoFurther() bool { return strings.Contains(m.unknown, read // readNoFurther marks, in what a manifest set aside, that it was not read past it. const readNoFurther = "(read no further: " + +// Predefined is the list of predefined keys the manifest was read with (ReadTreeManifestWritten, or the judge's +// SetPredefined), nil when none: data of the contract a judge reads, never a rule. +func (m Manifest) Predefined() PredefinedList { return m.predefined } + +// SetPredefined keeps the list of predefined keys a manifest is read with, for what reads it after (the judge). +func (m *Manifest) SetPredefined(list PredefinedList) { m.predefined = list } + +// FormsWritten is the fields the manifest's config was written with, in the config form (`config`, `takes-config`, +// `${config:…}`) and in the settings form (novox/hq ADR 0348 §1): data a judge reads to say a manifest mixes them. +func (m Manifest) FormsWritten() (config, settings []string) { return m.forms.config, m.forms.settings } diff --git a/go/catalogue/handed_consumers_test.go b/go/catalogue/handed_consumers_test.go index a93f6ea..abef0b9 100644 --- a/go/catalogue/handed_consumers_test.go +++ b/go/catalogue/handed_consumers_test.go @@ -1,8 +1,6 @@ package catalogue import ( - "os" - "path/filepath" "strings" "testing" ) @@ -103,32 +101,3 @@ func TestAProvidersConsumersFileIsLeftOutWhereItIsNotHanded(t *testing.T) { t.Fatalf("handed: said %q, filled %v", c.HandedOnly, c.Filled) } } - -// What `module check` and the merge gate judge: the provider read from its tree, its text inline. Judged by the same -// rules, a provider naming a provision it does not provide is refused there too, and one that does composes. -func TestAProviderReadFromItsTreeIsJudgedByTheSameRules(t *testing.T) { - read := func(text string) (Manifest, error) { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "files", "consumers.json"), []byte(text), 0o644); err != nil { - t.Fatal(err) - } - m, err := ParseManifest([]byte(handedProvider)) - if err != nil { - t.Fatal(err) - } - return WithTemplatesFrom(m, dir) - } - filled, err := read(consumersText) - if err != nil { - t.Fatalf("a provider read from its tree is refused: %v", err) - } - if _, err := notifierOn(t, filled).Compose(handingTo("store:5000")); err != nil { - t.Fatalf("a provider read from its tree does not compose where it would be handed: %v", err) - } - if _, err := read("${consumers:cache}"); err == nil || !strings.Contains(err.Error(), "does not provide") { - t.Fatalf("a provider read from its tree naming a provision it does not provide: %v", err) - } -} diff --git a/go/catalogue/predefined.go b/go/catalogue/predefined.go index ce0f264..2f0e7d0 100644 --- a/go/catalogue/predefined.go +++ b/go/catalogue/predefined.go @@ -236,9 +236,9 @@ func ReplacePublished(list PredefinedList) (was PredefinedList) { } // ReadTreeManifestWritten is the manifest in a module's directory of a repository tree as written, still naming its -// templates, judged against the list of predefined keys at the tree's root as the merge gate judges it -// (cmd/mesh-controller). What a build resolves, given the text it reads back from the archive it packs the templates -// into (Built.Texts, ReadTemplates). +// templates, decoded and judged by nothing (novox/hq ADR 0578 §3: the indexer's judge judges manifests), the tree's +// list of predefined keys read so a tree whose list does not read is still refused. What a build resolves, given the +// text it reads back from the archive it packs the templates into (Built.Texts, ReadTemplates). func ReadTreeManifestWritten(root, dir string) (Manifest, error) { list, err := ReadPredefined(root) if err != nil { @@ -248,7 +248,12 @@ func ReadTreeManifestWritten(root, dir string) (Manifest, error) { if err != nil { return Manifest{}, err } - return ParseManifestWith(raw, list) + m, err := Decode(raw) + if err != nil { + return Manifest{}, err + } + m.predefined = list + return m, nil } // ReadTreeManifest is ReadTreeManifestWritten with each template's text read from the module's directory as the diff --git a/go/catalogue/setting_in_owner_test.go b/go/catalogue/setting_in_owner_test.go index 6e8f359..00affaa 100644 --- a/go/catalogue/setting_in_owner_test.go +++ b/go/catalogue/setting_in_owner_test.go @@ -1,8 +1,6 @@ package catalogue import ( - "os" - "path/filepath" "strings" "testing" ) @@ -181,33 +179,3 @@ func TestAnOwnersKeyIsTheTerminals(t *testing.T) { } } } - -// A key asked only in a template is not refused while the manifest still names its templates (issue 817), and -// is judged once they are filled: WithTemplatesFrom over a tree whose template does not ask for it refuses it. -func TestAnOwnerSettingAskedOnlyInATemplateIsJudgedOnceFilled(t *testing.T) { - raw := `{"module":"sonarr","version":"1", - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, - "resources":[{"id":"recycle-bin","type":"directory","path":"/srv/sonarr/recycle","mode":"0755","owner":"${setting:puid}:${setting:pgid}"}, - {"id":"identity","type":"file","path":"/srv/sonarr/identity.env","mode":"0644","template":"files/identity.env"}]}` - m, err := ParseManifest([]byte(raw)) - if err != nil { - t.Fatalf("a templated manifest was refused: %v", err) - } - write := func(text string) string { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "files", "identity.env"), []byte(text), 0o644); err != nil { - t.Fatal(err) - } - return dir - } - if _, err := WithTemplatesFrom(m, write("PUID=${setting:puid}\nPGID=${setting:pgid}\n")); err != nil { - t.Errorf("a template asking for both keys was refused: %v", err) - } - _, err = WithTemplatesFrom(m, write("NOTHING=1\n")) - if err == nil || !strings.Contains(err.Error(), "asks for \"puid\" nowhere else") { - t.Errorf("a template not asking for puid was not refused: %v", err) - } -} diff --git a/go/catalogue/template.go b/go/catalogue/template.go index 0759b7a..455afd8 100644 --- a/go/catalogue/template.go +++ b/go/catalogue/template.go @@ -422,10 +422,12 @@ func WithTemplatesFrom(m Manifest, dir string) (Manifest, error) { if err != nil { return Manifest{}, err } - again, err := parseManifest(raw, m.predefined) + // Decoded again, never judged (novox/hq ADR 0578 §3): the judge judges what this answers where it judges. + again, err := Decode(raw) if err != nil { return Manifest{}, err } + again.predefined = m.predefined again.Moved = m.Moved return again, nil } diff --git a/go/cmd/mesh-compose/main_test.go b/go/cmd/mesh-compose/main_test.go index df60e35..f2d909b 100644 --- a/go/cmd/mesh-compose/main_test.go +++ b/go/cmd/mesh-compose/main_test.go @@ -123,7 +123,7 @@ func TestTheCommandAnswersWhatTheLinkedCompositionAnswers(t *testing.T) { in.Catalogue = map[string]catalogue.Manifest{} for name, m := range r.Cases[0].Manifests { // A request without a list of predefined keys reads a fallback as the indexer does (Request.Predefined). - parsed, err := catalogue.ParseManifestForInputs(m.Raw) + parsed, err := catalogue.Decode(m.Raw) got := v.Cases[0].Manifests[name] if (err == nil) != got.Accepted || (err != nil && err.Error() != got.Refused) { t.Errorf("%s: the manifest of %s: linked %v, command %+v", depth, name, err, got) diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index 45fbe64..167bb27 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -282,7 +282,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if err != nil { t.Fatal(err) } - f1, err := catalogue.ParseManifest(f1raw) + f1, err := catalogue.Decode(f1raw) if err != nil { t.Fatal(err) } diff --git a/go/composer/composer_test.go b/go/composer/composer_test.go index 9570894..e1ec9ea 100644 --- a/go/composer/composer_test.go +++ b/go/composer/composer_test.go @@ -79,7 +79,7 @@ func TestTheInputTravelsAsJSON(t *testing.T) { // The seats a node's assigned modules claim are held there when the request says the holdings come from the claims. func TestHoldingsComeFromTheClaims(t *testing.T) { - m, err := catalogue.ParseManifest([]byte(demo)) + m, err := catalogue.Decode([]byte(demo)) if err != nil { t.Fatal(err) } diff --git a/go/overlay/seat_test.go b/go/overlay/seat_test.go index d005546..8d472e6 100644 --- a/go/overlay/seat_test.go +++ b/go/overlay/seat_test.go @@ -18,8 +18,9 @@ func TestThePrivateNetworksClaimIsASeatTheMeshDefines(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := catalogue.ParseManifest(raw); err != nil { - t.Errorf("%s, composed by the control plane, is refused: %v", composed["module"], err) + // Decoded here; the indexer's judge judges it (mesh-delivery's judge tests hold it to every rule). + if _, err := catalogue.Decode(raw); err != nil { + t.Errorf("%s, composed by the control plane, does not decode: %v", composed["module"], err) } } seat, defined := catalogue.SeatNamed(TheNetwork) diff --git a/go/provisions/provisions_test.go b/go/provisions/provisions_test.go index 37a9796..a5c49ad 100644 --- a/go/provisions/provisions_test.go +++ b/go/provisions/provisions_test.go @@ -109,10 +109,6 @@ func TestAProvisionIsAReferenceUnderItsConsumersKey(t *testing.T) { if got := DependsOn(keycloak, vaultEntry, nil); len(got) != 0 { t.Errorf("the vault's secret depends on %v", got) } - if _, err := catalogue.ParseManifest([]byte(`{"module":"x","version":"1","provides":[{"name":"secret-db","scope":"mesh"}]}`)); err == nil || - !strings.Contains(err.Error(), "secret-") { - t.Errorf("a provision named secret-*: %v", err) - } if ProvisionsBucket != "mesh-provisioner_provisions" || PlansRead != "mesh-provisioner.plans" { t.Errorf("the states are %s and %s", ProvisionsBucket, PlansRead) } -- 2.54.0 From 68aaf235d4ea15a50959cbccb73ce05a648e6774 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 02:36:24 +0100 Subject: [PATCH 08/13] =?UTF-8?q?Take=20the=20judge=20out=20of=20mesh-sdk:?= =?UTF-8?q?=20it=20is=20mesh-delivery's=20now=20(ADR=200578=20=C2=A74,=20?= =?UTF-8?q?=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1585, S19: the strict parser, every manifest problem function and the judge's tables (192 declarations, about 5,200 lines) and their 244 tests leave; composition keeps its own refusals and the helpers it uses. The catalogue's tests decode the manifests they build. ParseManifest, ParseManifestWith, ParseManifestForInputs, CatalogueProblems and the rest are gone; mesh-delivery-indexer/judge holds them. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/a_service_process_test.go | 22 +- go/catalogue/accesses_test.go | 30 - go/catalogue/adoption_test.go | 9 - go/catalogue/agent_account_test.go | 36 +- go/catalogue/amqp_is_not_a_provision_test.go | 16 - go/catalogue/backup_test.go | 16 +- go/catalogue/bootstrap_cycle_test.go | 26 +- go/catalogue/brokered_test.go | 37 +- go/catalogue/bucket_shares_test.go | 6 +- go/catalogue/build.go | 350 ----- go/catalogue/build_system_test.go | 74 - go/catalogue/build_test.go | 83 +- go/catalogue/bus_snapshot_test.go | 51 +- go/catalogue/busy.go | 28 - go/catalogue/busy_test.go | 30 +- go/catalogue/catalogue_check_test.go | 76 - go/catalogue/collections.go | 211 +-- go/catalogue/collections_l3_test.go | 29 +- go/catalogue/collections_test.go | 38 +- go/catalogue/computed_test.go | 13 - go/catalogue/config.go | 266 ---- go/catalogue/config_rename_test.go | 38 +- go/catalogue/config_test.go | 237 +-- go/catalogue/consumer_into_serves.go | 49 - go/catalogue/consumes_start_test.go | 4 +- go/catalogue/contributes_test.go | 24 - .../contribution_dependencies_test.go | 21 - go/catalogue/core_credentials_test.go | 2 +- go/catalogue/cron.go | 77 - go/catalogue/data.go | 364 ----- go/catalogue/data_test.go | 121 +- go/catalogue/decode_for_test_test.go | 12 + go/catalogue/delivery_seat.go | 25 - go/catalogue/derived_for_consumer_test.go | 26 - go/catalogue/dir_into.go | 111 -- go/catalogue/dir_into_test.go | 51 - go/catalogue/endpoint_test.go | 47 - go/catalogue/engine_guard_test.go | 70 - go/catalogue/environment_into.go | 98 -- go/catalogue/environment_into_test.go | 122 -- go/catalogue/environment_items_test.go | 26 +- go/catalogue/events.go | 127 -- go/catalogue/files.go | 10 - go/catalogue/files_test.go | 14 - go/catalogue/filtering.go | 26 - go/catalogue/filtering_test.go | 40 +- go/catalogue/go_bundle_test.go | 31 - go/catalogue/graphical_session_test.go | 85 -- go/catalogue/groups_test.go | 20 - go/catalogue/handed.go | 37 - go/catalogue/handed_consumers_test.go | 2 +- go/catalogue/handed_providers_test.go | 64 +- go/catalogue/handed_pulled_in_test.go | 2 +- go/catalogue/handed_secrets_test.go | 31 - go/catalogue/handed_test.go | 20 +- go/catalogue/health.go | 201 --- go/catalogue/health_test.go | 68 +- go/catalogue/identity.go | 44 - go/catalogue/identity_bound_test.go | 106 -- go/catalogue/installation.go | 209 --- go/catalogue/installation_test.go | 92 -- go/catalogue/invokes_test.go | 15 +- go/catalogue/issued_credential_test.go | 41 - go/catalogue/issued_outside_test.go | 45 - go/catalogue/jail_problems_test.go | 48 - go/catalogue/kept_test.go | 10 - go/catalogue/kinded_test.go | 130 -- go/catalogue/localnetwork_test.go | 14 - go/catalogue/logs.go | 31 - go/catalogue/logs_test.go | 44 +- go/catalogue/manifest.go | 1294 ----------------- go/catalogue/mounts_test.go | 33 +- go/catalogue/naming.go | 16 - go/catalogue/naming_test.go | 49 +- go/catalogue/needs_test.go | 41 - go/catalogue/no_subjects_test.go | 19 - go/catalogue/optional_requirements.go | 99 -- go/catalogue/optional_requirements_test.go | 86 +- go/catalogue/outside_not_given_test.go | 2 +- go/catalogue/own_manifest_test.go | 2 +- go/catalogue/own_secret_owner_test.go | 20 +- go/catalogue/own_secret_prompt_test.go | 34 +- go/catalogue/own_secret_taken_test.go | 54 +- go/catalogue/paths_test.go | 40 - go/catalogue/placed_paths_test.go | 4 +- go/catalogue/placement.go | 147 -- go/catalogue/placement_test.go | 19 - go/catalogue/predefined_inputs.go | 40 - go/catalogue/predefined_inputs_test.go | 22 +- go/catalogue/predefined_test.go | 33 +- go/catalogue/prepares_test.go | 21 - go/catalogue/process_bus.go | 228 --- go/catalogue/process_bus_test.go | 141 +- go/catalogue/provided_test.go | 2 +- go/catalogue/public_issuer_test.go | 2 +- go/catalogue/published_ports_test.go | 2 +- go/catalogue/reads.go | 28 - go/catalogue/reads_test.go | 27 - go/catalogue/replaces_test.go | 30 +- go/catalogue/replay166_test.go | 2 +- go/catalogue/replay412_test.go | 35 - go/catalogue/resolve_keeps_references_test.go | 41 - go/catalogue/resolved_path_test.go | 93 +- go/catalogue/runonce_test.go | 23 +- go/catalogue/runtime_gate_test.go | 2 +- go/catalogue/runtime_health_test.go | 28 +- go/catalogue/runtime_test.go | 212 --- go/catalogue/schedule_test.go | 52 - go/catalogue/seat_contributions.go | 59 - go/catalogue/seat_contributions_test.go | 26 - go/catalogue/seat_data.go | 180 --- go/catalogue/seat_data_test.go | 156 +- go/catalogue/seat_invokes.go | 67 - go/catalogue/seat_paths.go | 105 -- go/catalogue/seat_paths_test.go | 182 --- go/catalogue/seats.go | 100 -- go/catalogue/seats_declared.go | 450 ------ go/catalogue/seats_declared_test.go | 232 --- go/catalogue/seats_test.go | 303 ---- go/catalogue/secret_family_test.go | 20 +- go/catalogue/served_by_setting_test.go | 2 +- go/catalogue/served_own_names.go | 80 - go/catalogue/served_own_names_test.go | 25 +- go/catalogue/setting_defaults.go | 127 -- go/catalogue/setting_defaults_test.go | 136 +- go/catalogue/setting_in_owner_test.go | 46 - go/catalogue/setting_in_unit_test.go | 29 - go/catalogue/setting_into.go | 114 -- go/catalogue/setting_takes.go | 42 - go/catalogue/setting_takes_test.go | 121 -- go/catalogue/several_contributions_test.go | 25 +- go/catalogue/several_secrets_test.go | 38 +- go/catalogue/shared_account_test.go | 2 +- go/catalogue/shared_credential_test.go | 31 - go/catalogue/showcase_manifest_test.go | 2 +- go/catalogue/state.go | 44 - go/catalogue/state_test.go | 55 +- go/catalogue/stream_work_queue_test.go | 32 +- go/catalogue/streams.go | 274 ---- go/catalogue/streams_test.go | 104 +- go/catalogue/template.go | 54 - go/catalogue/template_test.go | 62 +- go/catalogue/templates_dir_test.go | 34 +- go/catalogue/terminal_keys.go | 22 - go/catalogue/terminal_settings_test.go | 27 - go/catalogue/unconsumed_placeholder_test.go | 47 - go/catalogue/unhealthy.go | 25 - go/catalogue/unhealthy_test.go | 36 +- go/catalogue/upgrade.go | 18 - go/catalogue/upgrade_test.go | 68 - go/catalogue/upstream_file_test.go | 76 +- go/catalogue/user_namespaces_test.go | 34 - go/catalogue/verbs_test.go | 11 +- go/catalogue/waits_test.go | 28 +- go/catalogue/while_stopped_test.go | 55 +- go/catalogue/work_queue_consumer_test.go | 40 - go/catalogue/zones.go | 29 - go/catalogue/zones_test.go | 15 - 158 files changed, 121 insertions(+), 11371 deletions(-) create mode 100644 go/catalogue/decode_for_test_test.go diff --git a/go/catalogue/a_service_process_test.go b/go/catalogue/a_service_process_test.go index 1e25276..023d03d 100644 --- a/go/catalogue/a_service_process_test.go +++ b/go/catalogue/a_service_process_test.go @@ -2,7 +2,6 @@ package catalogue import ( "encoding/json" - "strings" "testing" ) @@ -31,7 +30,7 @@ func aServiceModule(t *testing.T) Manifest { "build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch", "from": "cmd/svc", "binary": "svc"}]} }` - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err != nil { t.Fatalf("the service's manifest is refused: %v", err) } @@ -145,22 +144,3 @@ func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) { t.Fatalf("the process replaces %s", got) } } - -func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) { - for what, resource := range map[string]string{ - "a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`, - "a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`, - "something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`, - "another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`, - "not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`, - } { - raw := `{"module":"m","version":"1","resources":[` + resource + `]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") { - t.Errorf("replaces on %s was accepted: %v", what, err) - } - } - ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}` - if _, err := ParseManifest([]byte(ok)); err != nil { - t.Errorf("a process replacing what its module no longer declares was refused: %v", err) - } -} diff --git a/go/catalogue/accesses_test.go b/go/catalogue/accesses_test.go index b95250a..0ebd1bf 100644 --- a/go/catalogue/accesses_test.go +++ b/go/catalogue/accesses_test.go @@ -76,33 +76,3 @@ func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) { t.Errorf("the refusal does not explain the ownership conflict: %v", err) } } - -// A manifest states an access's extent, the way a listening port states its source. An absolute -// path and a mode of read or read-write; anything else is refused rather than acted on wrongly. -func TestAnAccessIsValidated(t *testing.T) { - good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`) - if _, err := ParseManifest(good); err != nil { - t.Fatalf("a valid access was refused: %v", err) - } - - // Mode is optional and narrows to read — the safe default, because the danger with an access - // is being given more than was meant, not less. - bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`) - m, err := ParseManifest(bare) - if err != nil { - t.Fatalf("an access with no mode was refused: %v", err) - } - if m.Accesses[0].At() != AccessRead { - t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), AccessRead) - } - - relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`) - if _, err := ParseManifest(relative); err == nil { - t.Error("a relative access path was accepted") - } - - wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`) - if _, err := ParseManifest(wrong); err == nil { - t.Error("an access with an unknown mode was accepted") - } -} diff --git a/go/catalogue/adoption_test.go b/go/catalogue/adoption_test.go index 8630fa4..a812651 100644 --- a/go/catalogue/adoption_test.go +++ b/go/catalogue/adoption_test.go @@ -258,15 +258,6 @@ WantedBy=multi-user.target } } -func TestAGuardedPortMustBeAPort(t *testing.T) { - if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil { - t.Fatalf("guards is refused: %v", err) - } - if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil { - t.Fatal("guarding port 0 was accepted") - } -} - func keys[V any](m map[string]V) []string { return sortedKeys(m) } diff --git a/go/catalogue/agent_account_test.go b/go/catalogue/agent_account_test.go index 90efb04..204e866 100644 --- a/go/catalogue/agent_account_test.go +++ b/go/catalogue/agent_account_test.go @@ -43,7 +43,7 @@ const agentModule = `{"module": "agent", "version": "1", "resources": [ ]}` func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) { - m, err := ParseManifest([]byte(agentModule)) + m, err := decodeForTest([]byte(agentModule)) if err != nil { t.Fatal(err) } @@ -83,37 +83,3 @@ func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing t.Errorf("with no agent account, the agent's directory is the operator's: %v", home) } } - -func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) { - with := Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} - envOf := func(r Resolution) map[string]string { - t.Helper() - r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)} - out, err := r.Declaration(with) - if err != nil { - t.Fatal(err) - } - process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) - if process == nil { - t.Fatal("no runtime process was composed") - } - return process["env"].(map[string]string) - } - env := envOf(Resolution{Account: "ops", AgentAccount: "agent"}) - if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" { - t.Errorf("the runtime is not told whom agents run as: %v", env) - } - env = envOf(Resolution{Account: "ops"}) - if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" { - t.Errorf("with no agent account, agents run as the operator: %v", env) - } - env = envOf(Resolution{}) - if _, set := env[RuntimeAgentAccount]; set { - t.Errorf("a machine with no account names an agent account: %v", env) - } - if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"}, - Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 { - t.Error("a bundle may tell the runtime whom agents run as") - } -} diff --git a/go/catalogue/amqp_is_not_a_provision_test.go b/go/catalogue/amqp_is_not_a_provision_test.go index 11d1ad5..adbeb2d 100644 --- a/go/catalogue/amqp_is_not_a_provision_test.go +++ b/go/catalogue/amqp_is_not_a_provision_test.go @@ -14,22 +14,6 @@ import ( // the old wire protocol asks for the one server being retired, so both directions are refused at the // parser — this is judged from the manifest alone, no store needed. -func TestAManifestProvidingAmqpIsRefused(t *testing.T) { - raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`) - _, err := ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) { - t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err) - } -} - -func TestAManifestRequiringAmqpIsRefused(t *testing.T) { - raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`) - _, err := ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) { - t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err) - } -} - // And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under // design 28 task 5.4, not converted. func TestNoCatalogueManifestNamesAmqp(t *testing.T) { diff --git a/go/catalogue/backup_test.go b/go/catalogue/backup_test.go index 4450ac2..f45093f 100644 --- a/go/catalogue/backup_test.go +++ b/go/catalogue/backup_test.go @@ -5,28 +5,16 @@ import ( "testing" ) -// A backup contribution written by hand still names its module's own directories; one it does not -// declare is refused where it is written rather than reaching the holder as the literal text. (The -// catalogue check refuses a hand-written backup line at all since ADR 0233; parsing still reads one, -// because a module on the shelf was written before.) -func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"pg","version":"1", - "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:nowhere}"}]}`)) - if err == nil || !strings.Contains(err.Error(), "nowhere") { - t.Fatalf("a backup of an undeclared directory was accepted: %v", err) - } -} - // A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no // data, is still backed up: its lines are placed as written, each module's under a comment naming it. func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) { - pg, err := ParseManifest([]byte(`{"module":"pg","version":"1", + pg, err := decodeForTest([]byte(`{"module":"pg","version":"1", "resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}], "contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`)) if err != nil { t.Fatal(err) } - mail, err := ParseManifest([]byte(`{"module":"mail","version":"1", + mail, err := decodeForTest([]byte(`{"module":"mail","version":"1", "resources":[{"id":"spool","type":"directory","mode":"0700"}], "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`)) if err != nil { diff --git a/go/catalogue/bootstrap_cycle_test.go b/go/catalogue/bootstrap_cycle_test.go index 9cbbd67..fb26088 100644 --- a/go/catalogue/bootstrap_cycle_test.go +++ b/go/catalogue/bootstrap_cycle_test.go @@ -1,34 +1,12 @@ package catalogue import ( - "strings" "testing" ) -// The module that provides the artifact store may not be delivered through it. -// -// Building publishes to the store and the builder will not start without one, so a module that -// provides the store and also builds something asks the mesh to put an artifact into the thing -// that artifact is needed to create. On a mesh new enough to have no registry, that is a build -// that never returns (novox/hq 04-ISSUES/029). -func TestTheArtifactStoreCannotBeDeliveredThroughItself(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"registry","version":"1",` + - `"provides":[{"name":"artifact-store","scope":"mesh"}],` + - `"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"registry:2"}]},` + - `"resources":[{"id":"store","type":"container","name":"mesh-registry",` + - `"artifact":"registry","ports":["5000:5000"]}]}`)) - if err == nil { - t.Fatal("a registry module that builds its own image was accepted; the build has " + - "nowhere to publish until the module it belongs to is already running") - } - if !strings.Contains(err.Error(), "artifact-store") { - t.Fatalf("refused without naming the provision the cycle turns on: %v", err) - } -} - // Naming the image directly is the way out, and must stay accepted. func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"registry","version":"1",` + + _, err := decodeForTest([]byte(`{"module":"registry","version":"1",` + `"provides":[{"name":"artifact-store","scope":"mesh"}],` + `"resources":[{"id":"store","type":"container","name":"mesh-registry",` + `"image":"registry@sha256:` + @@ -41,7 +19,7 @@ func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) { // And an ordinary module still builds whatever it likes. func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"forge","version":"1",` + + _, err := decodeForTest([]byte(`{"module":"forge","version":"1",` + `"build":{"artifacts":[{"name":"forge","kind":"upstream","from":"gitea/gitea:1.22"}]},` + `"resources":[{"id":"run","type":"container","name":"forge","artifact":"forge"}]}`)) if err != nil { diff --git a/go/catalogue/brokered_test.go b/go/catalogue/brokered_test.go index c379f71..0ff4800 100644 --- a/go/catalogue/brokered_test.go +++ b/go/catalogue/brokered_test.go @@ -174,7 +174,7 @@ func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) { func TestTheShortFormStillMeansHere(t *testing.T) { // `"provides": ["shell"]` must keep meaning what it meant, or every existing manifest // silently changes meaning. - m, err := ParseManifest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`)) + m, err := decodeForTest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`)) if err != nil { t.Fatal(err) } @@ -183,19 +183,6 @@ func TestTheShortFormStillMeansHere(t *testing.T) { } } -func TestASiteScopedProvisionIsRefused(t *testing.T) { - // Meaningful for a claim — one DHCP server per segment — and not yet meaningful for a - // provision, because nothing knows how to reach "the one at my site". - _, err := ParseManifest([]byte( - `{"module":"dns","version":"1","provides":[{"name":"resolver","scope":"site"}]}`)) - if err == nil { - t.Fatal("a site-scoped provision was accepted") - } - if !strings.Contains(err.Error(), "site") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - // Being told, which is the difference between knowing and being able to. func boundShelf() map[string]Manifest { @@ -308,25 +295,3 @@ func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) { } } } - -func TestBindingWhatYouDoNotRequireIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"app","version":"1", - "binds":{"postgres-database":"/etc/app/db.json"}}`)) - if err == nil { - t.Fatal("a module was told about something it never asked for") - } - if !strings.Contains(err.Error(), "does not require") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - -func TestServingWhatYouDoNotProvideIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"app","version":"1", - "serves":{"postgres-database":{"port":5432}}}`)) - if err == nil { - t.Fatal("a module served something it does not provide") - } - if !strings.Contains(err.Error(), "does not provide") { - t.Fatalf("unhelpful refusal: %v", err) - } -} diff --git a/go/catalogue/bucket_shares_test.go b/go/catalogue/bucket_shares_test.go index 87345f4..c35b297 100644 --- a/go/catalogue/bucket_shares_test.go +++ b/go/catalogue/bucket_shares_test.go @@ -88,7 +88,7 @@ func TestAMalformedShareIsRefused(t *testing.T) { "other require": `{"name":"route","shares":[{"to":"a","methods":["get"]}]}`, } for name, entry := range cases { - _, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`)) + _, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`)) if err == nil { t.Errorf("%s: accepted", name) continue @@ -98,7 +98,7 @@ func TestAMalformedShareIsRefused(t *testing.T) { } } // The stored form is held to the same rules. - _, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"], + _, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"], "requires-shares":{"s3-bucket":[{"to":"a","methods":[]}]}}`)) if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") { t.Errorf("the stored form was accepted: %v", err) @@ -169,7 +169,7 @@ func TestChangingOnlyTheSharesDoesNotRecreateTheConsumer(t *testing.T) { // 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused. func TestAConsumerCannotContributeSharesItself(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"files","version":"1", + _, err := decodeForTest([]byte(`{"module":"files","version":"1", "contributes":{"s3-bucket":{"shares":"x"}}, "requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`)) if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") { diff --git a/go/catalogue/build.go b/go/catalogue/build.go index 7c2a4b6..9dbce70 100644 --- a/go/catalogue/build.go +++ b/go/catalogue/build.go @@ -2,10 +2,7 @@ package catalogue import ( "fmt" - "net" - "net/url" "path" - "regexp" "sort" "strings" ) @@ -237,152 +234,6 @@ func withVersion(value any, version string) any { return value } -// checkBuild is the manifest's own account of what it builds. -func (b *Build) problems(module string) []string { - if b == nil { - return nil - } - var problems []string - seen := map[string]bool{} - for _, a := range b.Artifacts { - if a.Name == "" { - problems = append(problems, module+" builds an artifact with no name") - continue - } - if seen[a.Name] { - problems = append(problems, fmt.Sprintf( - "%s builds two artifacts called %q, and a resource naming it could mean either", - module, a.Name)) - } - seen[a.Name] = true - switch a.Kind { - case ArtifactImage, ArtifactArchive, ArtifactUpstream, ArtifactBundle, ArtifactPackage: - default: - problems = append(problems, fmt.Sprintf( - "%s: %q is a %q, and an artifact is %q, %q, %q or %q", - module, a.Name, a.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream, - ArtifactBundle+", "+ArtifactPackage)) - } - // **A bundle is built from the module itself, so it says a language instead.** Everything - // else names what it is built from: a Dockerfile, a directory, somebody else's reference. - // A bundle's source is the module's own directory by definition, and what it needs to say - // is which compiler — because the mesh chooses that, and cannot choose for a module that - // has not said. - if len(a.External) > 0 && (a.Kind != ArtifactBundle || a.Language != "typescript") { - problems = append(problems, fmt.Sprintf( - "%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+ - "one file with some kept out (novox/hq ADR 0193)", module, a.Name)) - } - if len(a.Env) > 0 && a.Kind != ArtifactBundle { - problems = append(problems, fmt.Sprintf( - "%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+ - "serves is given words (novox/hq ADR 0192); a container says its own environment", - module, a.Name, a.Kind)) - } - if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage { - // **Except for a language that compiles to a binary, where it names which one** - // (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own - // directory compiled whole, and naming a source would be describing its own build. A - // repository written in a compiled language holds several commands — the host and its - // bootstrap live in one, and the mesh needs the host — and "the module's own directory" - // is then not a package at all. So the compiled case may say which package, and says - // the module root by saying nothing. - if a.From != "" && !compilesToABinary(a.Language) { - problems = append(problems, fmt.Sprintf( - "%s: %q is a bundle and names what it is built from (%q). A bundle is built "+ - "from the module's own directory; what it says is the language", - module, a.Name, a.From)) - } - if strings.TrimSpace(a.Language) == "" { - problems = append(problems, fmt.Sprintf( - "%s: %q is a bundle and says no language, so nothing can choose a compiler "+ - "for it", module, a.Name)) - } - problems = append(problems, bundleEnvProblems(module, a)...) - // What the runtime loads is among what was compiled (ADR 0175): a name here that is - // not an entrypoint is a file the bundle does not contain, and the runtime would - // fail to import it on every machine rather than here. - for _, load := range a.Loads { - found := false - for _, e := range a.Entrypoints { - found = found || e == load - } - // A bundle compiled to a binary is one executable: the runtime loads that or nothing - // (novox/hq ADR 0193). - if bin := BinaryOf(a); bin != "" { - found = load == bin - } - if !found { - problems = append(problems, fmt.Sprintf( - "%s: %q says the runtime loads %q, which is not among its entrypoints — "+ - "what is loaded is compiled, so it is named there too", module, a.Name, load)) - } - } - // **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary - // is pinned to one operating system at link time so a host refuses to touch a machine - // it was not built for (novox/hq ADR 0005); an artifact that says nothing would be - // compiled for whatever the build machine happened to be, which reads as portable and - // is not. - if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" { - problems = append(problems, fmt.Sprintf( - "%s: %q is compiled to a binary and says no system, so it would be built for "+ - "whatever the build machine happens to be. Declare one artifact per "+ - "system: %s", module, a.Name, spokenSystems())) - } else if !compiled && strings.TrimSpace(a.System) != "" { - problems = append(problems, fmt.Sprintf( - "%s: %q names the system %q and is written in %q, which compiles to code that "+ - "runs anywhere — a system that decides nothing reads as though it did", - module, a.Name, a.System, a.Language)) - } else if compiled && !knownSystem(a.System) { - problems = append(problems, fmt.Sprintf( - "%s: %q is built for %q, and a system is %s", - module, a.Name, a.System, spokenSystems())) - } - } else { - if a.From == "" && a.URL == "" { - problems = append(problems, fmt.Sprintf( - "%s: %q says nothing about what it is built from", module, a.Name)) - } - if a.Language != "" { - problems = append(problems, fmt.Sprintf( - "%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+ - "everything else brings its own recipe", module, a.Name, a.Kind)) - } - } - if a.Compiles != "" { - if a.Kind != ArtifactImage { - problems = append(problems, fmt.Sprintf( - "%s: %q is a %q and names a Go package it compiles. Only an image's recipe is "+ - "told which; a bundle names its package in from (novox/hq ADR 0267)", module, a.Name, a.Kind)) - } else if c := strings.TrimSpace(a.Compiles); strings.HasPrefix(c, "/") || c == ".." || - strings.HasPrefix(c, "../") || strings.Contains(c, "/../") || strings.HasSuffix(c, "/..") { - problems = append(problems, fmt.Sprintf( - "%s: %q compiles %q, which leaves the tree it is built in", module, a.Name, a.Compiles)) - } - } - // An upstream image is named, not read from the repository, so the path rule does not - // apply to it — and applying it anyway would refuse every reference with a registry host - // in it. - if a.Kind != ArtifactUpstream && - (strings.HasPrefix(a.From, "/") || strings.Contains(a.From, "..")) { - // A build reads its own repository and nothing else. A path leaving it would make - // what gets built depend on whatever happens to be on the machine building it. - problems = append(problems, fmt.Sprintf( - "%s: %q is built from %q, which is outside its own repository", - module, a.Name, a.From)) - } - if a.Kind == ArtifactUpstream && !strings.Contains(a.From, ":") { - // Without a tag or digest, what gets mirrored is whatever `latest` means today, and - // a module pinned to that is not pinned. - problems = append(problems, fmt.Sprintf( - "%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From)) - } - problems = append(problems, upstreamFileProblems(module, a)...) - problems = append(problems, pathsProblems(module, a)...) - } - return problems -} - // NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the // paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact // names paths; otherwise none, and the module keeps its directory's tree. @@ -410,122 +261,6 @@ func (m Manifest) NamedPaths() (paths []string, every bool) { return paths, true } -// pathsProblems is what is wrong with an artifact's `paths` (novox/hq ADR 0499 clause 1): named on a kind whose build -// source is derived or named otherwise, or a path that leaves the module's directory, is its whole directory, or is a -// pattern. -func pathsProblems(module string, a Artifact) []string { - if a.Paths == nil { - return nil - } - switch { - case a.Kind == ArtifactBundle && a.Language == "go": - return []string{fmt.Sprintf("%s: %q is a Go program and names paths: its build source is derived from the "+ - "package it compiles (novox/hq ADR 0267 clause 1, ADR 0499 clause 1)", module, a.Name)} - case a.Kind != ArtifactBundle && a.Kind != ArtifactPackage: - return []string{fmt.Sprintf("%s: %q is a %q and names paths; only a package or a bundle in a language other "+ - "than Go names the paths it is built from (novox/hq ADR 0499 clause 1)", module, a.Name, a.Kind)} - case len(a.Paths) == 0: - return []string{fmt.Sprintf("%s: %q names no path in paths; leave the word out to build from the module's "+ - "directory", module, a.Name)} - } - var problems []string - for _, p := range a.Paths { - clean := path.Clean(p) - switch { - case strings.TrimSpace(p) == "": - problems = append(problems, fmt.Sprintf("%s: %q names an empty path", module, a.Name)) - case strings.HasPrefix(p, "/") || clean == ".." || strings.HasPrefix(clean, "../"): - problems = append(problems, fmt.Sprintf("%s: %q names the path %q, which leaves the module's directory", - module, a.Name, p)) - case clean == ".": - problems = append(problems, fmt.Sprintf("%s: %q names the module's whole directory (%q) as a path; leave "+ - "the word out for that", module, a.Name, p)) - case strings.ContainsAny(p, "*?[]{}\\"): - problems = append(problems, fmt.Sprintf("%s: %q names %q, which is a pattern; paths names files and "+ - "directories", module, a.Name, p)) - } - } - return problems -} - -// reservedSuffixes are names no public vendor publishes under: an installation's own and documentation's. -var reservedSuffixes = []string{".internal", ".local", ".lan", ".home", ".corp", ".localhost", ".home.arpa", - ".test", ".invalid", ".example", ".intranet", ".private"} - -// notAPublicHost says why a host is not a public vendor's name, or "". -func notAPublicHost(host string) string { - h := strings.ToLower(strings.TrimSuffix(host, ".")) - if net.ParseIP(h) != nil { - return "an address rather than a name" - } - if !strings.Contains(h, ".") { - return "a name with no domain" - } - for _, s := range reservedSuffixes { - if strings.HasSuffix(h, s) { - return "a name under " + s + ", which is nobody's public domain" - } - } - return "" -} - -// sha256Hex is a pinned sha256 as a manifest writes it: 64 lower-case hexadecimal characters. -var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`) - -// upstreamFileProblems is what is wrong with an archive pinned by URL (novox/hq ADR 0316 §2, §6): it is an -// archive, fetched over https, pinned by a sha256 and a version, and it names no directory of the repository -// besides; a file name or a directory within is a plain path that stays where it is put. -func upstreamFileProblems(module string, a Artifact) []string { - pinned := a.URL != "" || a.SHA256 != "" || a.Version != "" || a.File != "" || a.Within != "" - if !pinned { - return nil - } - var problems []string - say := func(format string, args ...any) { - problems = append(problems, fmt.Sprintf("%s: %q ", module, a.Name)+fmt.Sprintf(format, args...)) - } - if a.Kind != ArtifactArchive { - say("is a %q and names an upstream file (url, sha256, version, file, within): only an archive is "+ - "mirrored from one (novox/hq ADR 0316)", a.Kind) - return problems - } - if a.URL == "" { - say("names a sha256, a version, a file or a directory within, and no url to fetch it from") - return problems - } - if a.From != "" { - say("is built from %q and fetched from %s: an archive is one or the other", a.From, a.URL) - } - if u, err := url.Parse(a.URL); err != nil || u.Scheme != "https" || u.Host == "" { - say("is fetched from %q, and an upstream file is fetched over https from a named host", a.URL) - } else if why := notAPublicHost(u.Hostname()); why != "" { - say("is fetched from %s, %s: an upstream file is somebody else's release, published under a public name", a.URL, why) - } - if !sha256Hex.MatchString(a.SHA256) { - say("is fetched from %s without a pinned sha256 (64 lower-case hexadecimal characters): what that "+ - "address serves changes under the mesh, and only the digest says it is what was reviewed", a.URL) - } - if strings.TrimSpace(a.Version) == "" { - say("pins %s and says no version, so a person reading the pin cannot tell which release it is", a.URL) - } - if a.File != "" && a.Within != "" { - say("names a file (%q) and a directory within (%q): a file is kept whole, a directory is taken out "+ - "of an archive", a.File, a.Within) - } - for field, p := range map[string]string{"file": a.File, "within": a.Within} { - if p == "" { - continue - } - clean := path.Clean(p) - if clean != p || strings.HasPrefix(p, "/") || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || - (field == "file" && strings.Contains(p, "/")) { - say("names %s %q, which is not a plain relative %s", field, p, map[string]string{ - "file": "file name", "within": "directory"}[field]) - } - } - return problems -} - // oneOrOther keeps the message readable for one artifact and for several, because a message that // says "neither" about one thing reads as a bug in the message. func oneOrOther(n int) string { @@ -535,30 +270,6 @@ func oneOrOther(n int) string { return "them" } -// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005). -// -// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain -// would call an operating system — the difference between two of them is a C library, not a kernel. -// They are what a machine reports itself to be and what a host is linked to refuse, so the list that -// matters is the one the host understands. -var systems = []string{"alpine", "android", "arch"} - -// knownSystem is whether the mesh builds for it. -func knownSystem(system string) bool { - want := strings.ToLower(strings.TrimSpace(system)) - for _, s := range systems { - if s == want { - return true - } - } - return false -} - -// spokenSystems is the list as a refusal says it, so a reader is one edit from right. -func spokenSystems() string { - return strings.Join(systems, ", ") -} - // compilesToABinary is whether this language's bundle is a binary for one operating system rather // than code that runs wherever its interpreter does. // @@ -599,42 +310,6 @@ func versionOf(digest string) string { return hex } -// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be -// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192). -var bundleEnvWords = map[string]bool{ - RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true, - RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true, -} - -// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192): -// a value is a path or a constant written with the references a container's environment may use -// for a place or a port, and never a secret's content or another module's binding — a secret -// reaches a tool as a file whose path is named. -func bundleEnvProblems(module string, a Artifact) []string { - if len(a.Env) == 0 { - return nil - } - var problems []string - for _, word := range sortedKeys(a.Env) { - value := a.Env[word] - if bundleEnvWords[word] { - problems = append(problems, fmt.Sprintf( - "%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+ - "given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)", - module, a.Name, word)) - } - rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "") - if strings.Contains(rest, "${") { - problems = append(problems, fmt.Sprintf( - "%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+ - "${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+ - "named by its path, never as its content (novox/hq ADR 0192)", - module, a.Name, word, value)) - } - } - return problems -} - func copyWords(in map[string]string) map[string]string { if len(in) == 0 { return nil @@ -675,31 +350,6 @@ func runByAResource(m Manifest) map[string]bool { return named } -// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq -// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says -// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or -// it is the runtime itself. One reached by none of them was built, recorded and pushed as success, -// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here, -// naming the field that would deliver it. -func undeliveredBundles(m Manifest) []string { - if m.Build == nil || m.Module == RuntimeModule { - return nil - } - named := runByAResource(m) - var problems []string - for _, a := range m.Build.Artifacts { - if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 { - continue - } - problems = append(problems, fmt.Sprintf( - "%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+ - "unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+ - "module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)", - m.Module, a.Name)) - } - return problems -} - // BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs, // runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a // bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a diff --git a/go/catalogue/build_system_test.go b/go/catalogue/build_system_test.go index e1a97dd..99cefec 100644 --- a/go/catalogue/build_system_test.go +++ b/go/catalogue/build_system_test.go @@ -1,82 +1,8 @@ package catalogue -import ( - "strings" - "testing" -) - // bundleFor is a manifest whose one artifact is a bundle in the given language and system. func bundleFor(language, system string) Manifest { return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{ {Name: "binary", Kind: ArtifactBundle, Language: language, System: system}, }}} } - -func problemsOf(t *testing.T, m Manifest) string { - t.Helper() - return strings.Join(m.Build.problems(m.Module), "\n") -} - -// **A language that compiles to a binary must say which system.** -// -// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it -// was not built for. An artifact that says nothing would be compiled for whatever the build machine -// happened to be — which reads as portable and is not, and is the fault this check exists for. -func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) { - got := problemsOf(t, bundleFor("go", "")) - if !strings.Contains(got, "says no system") { - t.Fatalf("a compiled bundle with no system was accepted:\n%s", got) - } - // And the refusal names what it could have said, so a reader is one edit from right. - for _, system := range []string{"alpine", "android", "arch"} { - if !strings.Contains(got, system) { - t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got) - } - } -} - -func TestABinaryThatNamesASystemIsAccepted(t *testing.T) { - if got := problemsOf(t, bundleFor("go", "arch")); got != "" { - t.Fatalf("a compiled bundle naming a system was refused:\n%s", got) - } -} - -// A system the mesh does not build for is refused where it is written. These are the host's own -// names, not a compiler's: the difference between two of them is a C library rather than a kernel, -// so a value that looks like an operating system to a toolchain is still wrong here. -func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) { - for _, wrong := range []string{"linux", "debian", "darwin"} { - got := problemsOf(t, bundleFor("go", wrong)) - if !strings.Contains(got, "and a system is") { - t.Fatalf("%q was accepted as a system:\n%s", wrong, got) - } - } -} - -// **And a language that runs anywhere must not name one.** A system that decides nothing reads as -// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045). -func TestAPortableBundleMayNotNameASystem(t *testing.T) { - got := problemsOf(t, bundleFor("typescript", "arch")) - if !strings.Contains(got, "runs anywhere") { - t.Fatalf("a portable bundle was allowed to name a system:\n%s", got) - } -} - -func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) { - if got := problemsOf(t, bundleFor("typescript", "")); got != "" { - t.Fatalf("an ordinary bundle was refused:\n%s", got) - } -} - -// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the -// reason the target is the artifact's rather than the recipe's. -func TestOneArtifactPerSystemIsAccepted(t *testing.T) { - m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{ - {Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"}, - {Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"}, - {Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"}, - }}} - if got := problemsOf(t, m); got != "" { - t.Fatalf("one artifact per system was refused:\n%s", got) - } -} diff --git a/go/catalogue/build_test.go b/go/catalogue/build_test.go index c17c0eb..aa9be4c 100644 --- a/go/catalogue/build_test.go +++ b/go/catalogue/build_test.go @@ -108,68 +108,8 @@ func TestAModuleThatBuildsNothingIsOrdinary(t *testing.T) { } } -func TestTwoArtifactsWithOneNameAreRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"image","from":"Dockerfile"}, - {"name":"x","kind":"archive","from":"files"}]}}`)) - if err == nil { - t.Fatal("two artifacts with one name were accepted") - } - if !strings.Contains(err.Error(), "could mean either") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - -func TestAnArtifactBuiltFromOutsideItsRepositoryIsRefused(t *testing.T) { - // A build reads its own repository and nothing else. A path leaving it would make what gets - // built depend on whatever happens to be on the machine doing the building. - for _, from := range []string{"/etc/passwd", "../elsewhere"} { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"archive","from":"` + from + `"}]}}`)) - if err == nil { - t.Fatalf("%q was accepted as a build input", from) - } - if !strings.Contains(err.Error(), "outside its own repository") { - t.Fatalf("refused for the wrong reason: %v", err) - } - } -} - -func TestAnArtifactOfAnUnknownKindIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"binary","from":"main.go"}]}}`)) - if err == nil { - t.Fatal("an artifact of an unknown kind was accepted") - } -} - -func TestAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) { - // The SDK's shape: a package built from the module's own directory, naming a language. - m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk", - "build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]}, - "resources":[]}`)) - if err != nil { - t.Fatalf("the SDK's package manifest did not parse: %v", err) - } - if m.Build.Artifacts[0].Kind != ArtifactPackage { - t.Fatalf("expected a package artifact, got %q", m.Build.Artifacts[0].Kind) - } - - // A package that names what it is built from is refused, exactly as a bundle is: it is built - // from the module's own directory. - if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"lib","kind":"package","language":"typescript","from":"Dockerfile"}]}}`)); err == nil { - t.Fatal("a package naming a source was accepted") - } - // A package with no language cannot choose a toolchain. - if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"lib","kind":"package"}]}}`)); err == nil { - t.Fatal("a package with no language was accepted") - } -} - func TestAResourceNamingAPackageIsRefused(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"a","version":"1","slug":"a", + m, err := decodeForTest([]byte(`{"module":"a","version":"1","slug":"a", "build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]}, "resources":[{"id":"svc","type":"container","name":"a","artifact":"lib"}]}`)) if err != nil { @@ -180,24 +120,3 @@ func TestAResourceNamingAPackageIsRefused(t *testing.T) { t.Fatal("a resource backed by a package was accepted; a package is not a resource") } } - -// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in, -// and only an image says one. -func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`)) - if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" { - t.Fatalf("an image naming its package was refused or lost it: %v", err) - } - for _, c := range []struct{ artifact, says string }{ - {`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"}, - {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"}, - {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"}, - {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"}, - } { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says) - } - } -} diff --git a/go/catalogue/bus_snapshot_test.go b/go/catalogue/bus_snapshot_test.go index 5f8edb3..69bd737 100644 --- a/go/catalogue/bus_snapshot_test.go +++ b/go/catalogue/bus_snapshot_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "encoding/json" "os" "path/filepath" "strings" @@ -10,54 +9,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/internal/beside" ) -// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and -// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted -// nothing, so it is refused at the parser rather than silently dropped. -func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) { - raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], - "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, - "emits":["something.happened"]}`) - _, err := ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") { - t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err) - } - - // Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is - // granted the module's events, so a bus module that ships one may declare them. - bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], - "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, - "emits":["bus.slow-consumer"],"tools":["bus_streams"], - "build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`) - m, err := ParseManifest(bundled) - if err != nil { - t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err) - } - // And so does its built manifest, its bundle in Bundles and its build read away (issue 774: nats's built manifest - // was refused, and with it every plan of the control-node). - built, err := m.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, Reference: "store/tools:1", Digest: "sha256:1"}}) - if err != nil { - t.Fatal(err) - } - raw, err = json.Marshal(built) - if err != nil { - t.Fatal(err) - } - if _, err := ParseManifestForInputs(raw); err != nil { - t.Fatalf("the built manifest of a bus module whose tools bundle declares what it emits was refused: %v", err) - } - - quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], - "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, - "tools":["bus_streams"]}`) - m, err = ParseManifest(quiet) - if err != nil { - t.Fatalf("a bus module with an account and tools was refused: %v", err) - } - if !m.ClaimsSeat(BrokerSeat) { - t.Fatal("it does not read as holding the broker seat") - } -} - // The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a // dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot // program in the bus's own container. @@ -66,7 +17,7 @@ func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) { if err != nil { t.Fatal(err) } - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/busy.go b/go/catalogue/busy.go index 18cbbf0..bf56b3a 100644 --- a/go/catalogue/busy.go +++ b/go/catalogue/busy.go @@ -80,34 +80,6 @@ func ReadBusy(r map[string]any) (Busy, bool, []string) { return b, true, problems } -// busyProblems is everything wrong with a manifest's `busy` fields, in the manifest's words: a command on -// one line, a timeout within its bounds, and only on a container that stays up. -func busyProblems(m Manifest) []string { - var problems []string - for _, r := range m.Resources { - b, has, read := ReadBusy(r) - if !has { - continue - } - where := m.Module + ": " + fmt.Sprint(r["id"]) - for _, p := range read { - problems = append(problems, m.Module+": "+p) - } - if fmt.Sprint(r["type"]) != "container" || !LongRunning(r) { - problems = append(problems, fmt.Sprintf("%s declares busy and is not a container that stays up: busy is "+ - "asked of a running container before it is replaced (ADR 0335)", where)) - } - if b.Command == "" { - problems = append(problems, where+"'s busy names no command: one run inside the container, exiting 0 "+ - "while it is busy") - } - if strings.ContainsAny(b.Command, "\n\r") { - problems = append(problems, where+"'s busy command is one line") - } - } - return problems -} - // busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that // reads it, the command and its timeout written out; for one that does not, the field is not sent, and the // container is replaced at once, as before. diff --git a/go/catalogue/busy_test.go b/go/catalogue/busy_test.go index 6ffca3d..42ca6d4 100644 --- a/go/catalogue/busy_test.go +++ b/go/catalogue/busy_test.go @@ -2,7 +2,6 @@ package catalogue import ( "encoding/json" - "strings" "testing" "git.novox.be/novox/mesh-sdk/go/validate" @@ -32,35 +31,8 @@ func busyManifest(t *testing.T, on string, busy any) []byte { return out } -func TestABusyIsHeldToItsShape(t *testing.T) { - if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil { - t.Fatalf("a well-formed busy was refused: %v", err) - } - if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil { - t.Fatalf("a busy with its timeout was refused: %v", err) - } - for name, c := range map[string]struct { - on string - busy any - says string - }{ - "text": {"agent", "mesh-builder busy", "it is an object"}, - "no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"}, - "two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"}, - "a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"}, - "an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`}, - "on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"}, - "on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"}, - } { - _, err := ParseManifest(busyManifest(t, c.on, c.busy)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err) - } - } -} - func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) { - m, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})) + m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/catalogue_check_test.go b/go/catalogue/catalogue_check_test.go index ffc7cf8..b2f8e60 100644 --- a/go/catalogue/catalogue_check_test.go +++ b/go/catalogue/catalogue_check_test.go @@ -2,7 +2,6 @@ package catalogue import ( "path/filepath" - "strings" "testing" "git.novox.be/novox/mesh-sdk/go/internal/beside" @@ -45,78 +44,3 @@ func TestEveryCatalogueManifestParses(t *testing.T) { t.Fatal("no endpoint in the catalogue is named, so this proved nothing") } } - -// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no -// definition names a domain or a public address the mesh acts on, and every value that must for now -// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks. -func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) { - _, found := catalogueManifests(t) - var named []string - for _, r := range found { - m, err := r.manifest, r.err - if err != nil { - t.Errorf("%s: %v", r.path, err) - continue - } - named = append(named, InstallationProblems(m)...) - } - if len(named) > 0 { - t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n ")) - } -} - -// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR -// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a -// backup line by hand, every directory a container writes is declared, and every irreplaceable item is -// backed up — so a store added is a store backed up, without a list of stores to keep in step. -func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) { - _, found := catalogueManifests(t) - shelf := Shelf{} - granting := 0 - for _, r := range found { - m, err := r.manifest, r.err - if err != nil { - continue // TestEveryCatalogueManifestParses says why - } - if len(m.Grants) > 0 { - granting++ - } - shelf[m.Module] = m - } - for _, problem := range DataProblems(shelf) { - t.Error(problem) - } - if granting == 0 { - t.Fatal("no module in the catalogue grants a provision, so this proved nothing") - } -} - -// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every -// module's identity, on the longest machine name, fits the bound of every provision it wants. An -// overflow fails here, in the pull request that introduces it, rather than on the provider's machine -// the first time a real machine's name meets the module's (issue 263). -func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) { - _, found := catalogueManifests(t) - shelf := Shelf{} - for _, r := range found { - m, err := r.manifest, r.err - if err != nil { - t.Fatalf("%s: %v", r.path, err) - } - shelf[m.Module] = m - } - for _, p := range IdentityProblems(shelf, DefaultLongestMachine) { - t.Error(p) - } - // The night it was found: the resolver provision bounds nothing, and the object store still 20. - if dns, ok := shelf["dnsmasq"]; ok { - if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() { - t.Errorf("the resolver provision bounds its consumers' identities: %+v", b) - } - } - if store, ok := shelf["minio"]; ok { - if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 { - t.Errorf("the object store's access key is not bounded at 20: %+v", b) - } - } -} diff --git a/go/catalogue/collections.go b/go/catalogue/collections.go index c2a6562..3dffb3a 100644 --- a/go/catalogue/collections.go +++ b/go/catalogue/collections.go @@ -113,12 +113,7 @@ var oldCollectionNamespaces = []string{"shell", "environment", "contribution"} var ( // ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused. - ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) - // collectionName is words joined by colons, so an old namespace's placeholder names its collection as it reads. - collectionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`) - // itemName, attributeKey and attributeValue are the words an item's name and attributes are made of; none holds - // a colon, a comma or an equals sign, which tell a selector from a collection's name. - itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) ) @@ -242,190 +237,6 @@ func placerOfOld(name string) string { return canonicalSeat(seat) } -// collectionProblems is what is wrong with a module's collections and items, from the manifest alone. -func collectionProblems(m Manifest) []string { - var problems []string - declared := map[string]bool{} - for i, c := range m.Collections { - switch { - case !collectionName.MatchString(c.Name): - problems = append(problems, fmt.Sprintf("%s's collection %d is %q, which is not a collection's name: "+ - "words of lower-case letters, digits and hyphens, joined by colons", m.Module, i+1, c.Name)) - case declared[c.Name]: - problems = append(problems, fmt.Sprintf("%s collects %s twice", m.Module, c.Name)) - } - if _, old, _ := oldCollection(c.Name, false); old { - problems = append(problems, fmt.Sprintf("%s collects %s, which is read from its placeholder: a module "+ - "places it as ${%s} where it holds the seat that places it, and declares nothing", m.Module, c.Name, - c.Name)) - } - if strings.ContainsAny(c.Comment, "\n\r") { - problems = append(problems, fmt.Sprintf("%s's collection %s has a comment holding a line break", - m.Module, c.Name)) - } - declared[c.Name] = true - } - // A collection declared and placed nowhere is a word read by nothing; judged where every file's text is here. - if !m.Templated() { - named := map[string]bool{} - for _, r := range m.Resources { - if text, ok := r["content"].(string); ok { - for _, f := range ofCollected.FindAllStringSubmatch(text, -1) { - base, _ := splitSelector(f[1]) - named[base] = true - } - } - } - for _, c := range m.Collections { - if !named[c.Name] && collectionName.MatchString(c.Name) { - problems = append(problems, fmt.Sprintf("%s collects %s and no file of its places it as "+ - "${collected:%s}", m.Module, c.Name, c.Name)) - } - } - } - contributed := map[string]bool{} - for i, it := range m.Items { - where := fmt.Sprintf("%s's item %d", m.Module, i+1) - if !collectionName.MatchString(it.Collection) { - problems = append(problems, fmt.Sprintf("%s is to %q, which is not a collection's name", where, - it.Collection)) - } else if _, old, wrong := oldCollection(it.Collection, true); old && wrong != nil { - problems = append(problems, fmt.Sprintf("%s: %v", where, wrong)) - } - switch { - case it.Name != "" && !itemName.MatchString(it.Name): - problems = append(problems, fmt.Sprintf("%s: the name %q is not a name: lower-case letters, digits and "+ - "hyphens", where, it.Name)) - case contributed[it.key()]: - problems = append(problems, fmt.Sprintf("%s contributes to %s twice; a module contributes one item to "+ - "a collection, or several each with a name of its own", m.Module, it.key())) - case it.Name == "" && slices.ContainsFunc(m.Items, func(o Item) bool { - return o.Collection == it.Collection && o.Name != "" - }): - problems = append(problems, fmt.Sprintf("%s contributes several items to %s and %s has no name; each "+ - "of several items has a name of its own", m.Module, it.Collection, where)) - } - contributed[it.key()] = true - problems = append(problems, itemAttributeProblems(m.Module, where, it)...) - if it.Collection == EnvironmentCollection { - problems = append(problems, environmentItemProblems(where, it)...) - } - switch { - case it.Template == "" && it.Content == "": - problems = append(problems, fmt.Sprintf("%s names no template: an item is a template of the module's "+ - "own, \"template\": \"%s/\" (novox/hq ADR 0349 §13)", where, m.TemplatesDir())) - case it.Template != "" && it.Content != "": - problems = append(problems, fmt.Sprintf("%s names its template and says its content too", where)) - case it.Template != "": - if wrong := itemTemplateProblem(m, it.Template); wrong != "" { - problems = append(problems, fmt.Sprintf("%s: %s", where, wrong)) - } - // **Filled by the provisioner's client alone**: the controller renders no item. - if !m.Moved { - problems = append(problems, fmt.Sprintf("%s: an item is filled by the provisioner's client of the "+ - "node its contributor is placed on, so a module contributing one is marked moved "+ - "(\"moved\": true; novox/hq ADR 0349 §13, §17)", where)) - } - } - if it.Content != "" { - problems = append(problems, itemTextProblems(m.Module, it)...) - } - } - return problems -} - -// itemAttributeProblems is what is wrong with an item's attributes and capability. The attributes of an item of a kind -// received as data are the fields its shape places by (and `order`), each with a value the shape takes. -func itemAttributeProblems(module, where string, it Item) []string { - var problems []string - for _, k := range sortedKeys(it.Attributes) { - v := it.Attributes[k] - switch { - case !attributeKey.MatchString(k): - problems = append(problems, fmt.Sprintf("%s: the attribute %q is not a name: lower-case letters, digits "+ - "and hyphens", where, k)) - continue - case !attributeFits(it.Collection, k, v): - problems = append(problems, fmt.Sprintf("%s: %s=%q is not a value: lower-case letters, digits, hyphens, "+ - "dots and underscores (an environment variable's name, letters, digits and underscores)", where, k, v)) - continue - } - if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) { - problems = append(problems, fmt.Sprintf("%s: order=%q is not a whole number from 0 to 99", where, v)) - } - } - if namespace, key, _ := strings.Cut(it.Collection, ":"); namespace == "contribution" { - seat, kind, _ := strings.Cut(key, ":") - if _, r, ok := receivable(seat, kind); ok && r.Shape != nil { - for _, k := range sortedKeys(it.Attributes) { - var place *PlaceField - for i := range r.Shape.Places { - if r.Shape.Places[i].Field == k { - place = &r.Shape.Places[i] - } - } - switch { - case k == OrderAttribute: - case place == nil: - problems = append(problems, fmt.Sprintf("%s: %s is placed by %s, and %q is none of them", where, - it.Collection, placeFields(r), k)) - case !oneOf(place.Values, it.Attributes[k]): - problems = append(problems, fmt.Sprintf("%s: %s=%s; %s is one of %s", where, k, it.Attributes[k], k, - strings.Join(place.Values, ", "))) - } - } - } - } - switch { - case it.IfCapability == "": - case !capabilityName.MatchString(it.IfCapability): - problems = append(problems, fmt.Sprintf("%s: if-capability %q is not a capability's name", where, it.IfCapability)) - case !oneOf(KnownCapabilities, it.IfCapability): - problems = append(problems, fmt.Sprintf("%s: if-capability %q is one no machine reports; the node-engine "+ - "detects %s", where, it.IfCapability, strings.Join(KnownCapabilities, ", "))) - } - return problems -} - -// AttributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except -// the environment's `name`, which is a variable's name (design 57 L3, issue 795). The same rule is mesh-catalog's -// provisioner client's, and the inventory's. -func attributeFits(collection, k, v string) bool { - if collection == EnvironmentCollection && k == "name" { - return variableName.MatchString(v) - } - return attributeKey.MatchString(k) && attributeValue.MatchString(v) -} - -// environmentItemProblems is what is wrong with an item of the environment collection: typed by `kind`, a variable -// with its `name`, a PATH entry `at` the start or the end, and no attribute besides `order`. -func environmentItemProblems(where string, it Item) []string { - var problems []string - for _, k := range sortedKeys(it.Attributes) { - if !slices.Contains([]string{"kind", "name", "at", OrderAttribute}, k) { - problems = append(problems, fmt.Sprintf("%s: %s is typed by kind, name and at, and %q is none of them", - where, it.Collection, k)) - } - } - switch kind := it.Attributes["kind"]; kind { - case "variable": - if it.Attributes["name"] == "" || it.Attributes["at"] != "" { - problems = append(problems, fmt.Sprintf("%s: a variable names itself with name= and has no at", where)) - } else if it.Attributes["name"] == "PATH" { - problems = append(problems, fmt.Sprintf("%s: PATH is not a variable; a PATH entry is kind=path", where)) - } - case "path": - if it.Attributes["name"] != "" || (it.Attributes["at"] != PathAtStart && it.Attributes["at"] != PathAtEnd) { - problems = append(problems, fmt.Sprintf("%s: a PATH entry is at=%s or at=%s and has no name", where, - PathAtStart, PathAtEnd)) - } - default: - problems = append(problems, fmt.Sprintf("%s: an item of %s is kind=variable or kind=path, not %q", where, - it.Collection, kind)) - } - return problems -} - // itemTemplateProblem is what is wrong with an item's template path, or "". func itemTemplateProblem(m Manifest, name string) string { dir := m.TemplatesDir() @@ -437,26 +248,6 @@ func itemTemplateProblem(m Manifest, name string) string { return "" } -// itemTextProblems is what an item's text may not name: a secret or a bound value, since an item is kept in a -// bucket and no secret is (ADR 0349 clause 7), and a collection, since an item is not a collector. And every -// placeholder no pass fills, as in a file's content. -func itemTextProblems(module string, it Item) []string { - var problems []string - for _, f := range templatePlaceholder.FindAllStringSubmatch(it.Content, -1) { - namespace, key := f[1], f[2] - switch _, collection := collectionOfPlaceholder(namespace, key); { - case namespace == "secret" || namespace == "bound": - problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is kept in a bucket, where "+ - "no secret is (novox/hq ADR 0349 §7)", module, it.Collection, f[0])) - case collection: - problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is not a collector", - module, it.Collection, f[0])) - } - } - return append(problems, unconsumedPlaceholders(module, map[string]any{"id": "item:" + it.key(), - "type": "file", "content": it.Content})...) -} - // isOld is whether a collection's name is an old namespace's. func isOld(name string) bool { _, old, _ := oldCollection(name, true) diff --git a/go/catalogue/collections_l3_test.go b/go/catalogue/collections_l3_test.go index 4c687a4..6935def 100644 --- a/go/catalogue/collections_l3_test.go +++ b/go/catalogue/collections_l3_test.go @@ -69,7 +69,7 @@ func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testi raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1) raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) raw = strings.Replace(raw, `"moved":true,`, "", 1) - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err == nil { _, err = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{}) } @@ -82,33 +82,6 @@ func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testi } } -func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) { - item := func(extra string) string { - return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + - `"items":[` + extra + `]}` - } - for items, want := range map[string]string{ - `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name", - `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice", - `{"collection":"c","name":"Big","template":"files/a"}`: "is not a name", - `{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99", - `{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value", - `{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports", - `{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left", - `{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`, - `{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour", - } { - _, err := ParseManifest([]byte(item(items))) - if err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: %v, want %q", items, err, want) - } - } - ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`) - if _, err := ParseManifest([]byte(ok)); err != nil { - t.Fatal(err) - } -} - // A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item // name) and with the same lines as the contributions they replace. func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) { diff --git a/go/catalogue/collections_test.go b/go/catalogue/collections_test.go index c9aaf1b..f28c48c 100644 --- a/go/catalogue/collections_test.go +++ b/go/catalogue/collections_test.go @@ -37,7 +37,7 @@ func collectionsOn(t *testing.T) Resolution { t.Helper() zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText})) prompt := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText})) - aliases, err := ParseManifest([]byte(oldFormAliases)) + aliases, err := decodeForTest([]byte(oldFormAliases)) if err != nil { t.Fatal(err) } @@ -141,39 +141,3 @@ func TestAMovedContributorsItemRendersAsItsOldFormDid(t *testing.T) { } } } - -func TestCollectionsAndItemsAreJudged(t *testing.T) { - for name, c := range map[string]struct{ raw, says string }{ - "an item of a module not moved": {`{"module":"m","version":"1", - "items":[{"collection":"shell:zsh:normal","template":"files/x"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is marked moved"}, - "two items to one collection": {`{"module":"m","version":"1","moved":true, - "items":[{"collection":"a","template":"files/x"},{"collection":"a","template":"files/y"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "contributes to a twice"}, - "an item to no slot": {`{"module":"m","version":"1","moved":true, - "items":[{"collection":"shell:zsh:middle","template":"files/x"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is no slot"}, - "an item outside the templates": {`{"module":"m","version":"1","moved":true, - "items":[{"collection":"a","template":"x"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is not a file under files/"}, - "an item naming a secret": {`{"module":"m","version":"1", - "items":[{"collection":"a","content":"t=${secret:token}"}]}`, "where no secret is"}, - "a collection not declared": {`{"module":"m","version":"1", - "resources":[{"id":"f","type":"file","path":"/f","content":"${collected:a}"}]}`, "does not collect"}, - "an old namespace declared": {`{"module":"m","version":"1","collections":[{"name":"shell:zsh:normal"}], - "resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "read from its placeholder"}, - "a collection placed nowhere": {`{"module":"m","version":"1","collections":[{"name":"a"}], - "resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "no file of its places it"}, - "a collection in a path": {`{"module":"m","version":"1","collections":[{"name":"a"}], - "resources":[{"id":"f","type":"file","path":"/${collected:a}","content":"${collected:a}"}]}`, - "only in a file's content"}, - } { - _, err := ParseManifest([]byte(c.raw)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v, want it to say %q", name, err, c.says) - } - } - if _, err := ParseManifest([]byte(collectorZsh)); err != nil { - t.Errorf("a collector: %v", err) - } -} diff --git a/go/catalogue/computed_test.go b/go/catalogue/computed_test.go index ccdd198..3b46312 100644 --- a/go/catalogue/computed_test.go +++ b/go/catalogue/computed_test.go @@ -104,19 +104,6 @@ func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) { } } -func TestAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) { - // Otherwise nobody could say where a given file on a machine came from. - _, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1", - "computed":"mesh-network", - "resources":[{"id":"a","type":"package","package":"wireguard-tools"}]}`)) - if err == nil { - t.Fatal("a module that both ships files and has them computed was accepted") - } - if !strings.Contains(err.Error(), "one or the other") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - func TestSettingsApplyToAComputedModuleToo(t *testing.T) { // Being computed is about where the files come from, not about whether they are configurable. // A line drawn there would be arbitrary and nobody could predict it. diff --git a/go/catalogue/config.go b/go/catalogue/config.go index 0a54370..4d7c1fd 100644 --- a/go/catalogue/config.go +++ b/go/catalogue/config.go @@ -5,7 +5,6 @@ import ( "fmt" "regexp" "slices" - "strings" ) // A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660). @@ -123,26 +122,6 @@ func toConfigForm(raw []byte) ([]byte, error) { return raw, nil } -// inConfigWords is a refusal said of a module in the config form in its words: `${config:}` and -// takes-config, where the rules shared with the settings form say `${setting:}` and takes-settings. -func inConfigWords(problem string) string { - problem = settingRef.ReplaceAllStringFunc(problem, func(ref string) string { - return "${config:" + settingRef.FindStringSubmatch(ref)[1] + "}" - }) - return strings.ReplaceAll(problem, TakesSettingsField, TakesConfigField) -} - -// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1). -func MixedFormProblems(m Manifest) []string { - if len(m.forms.config) == 0 || len(m.forms.settings) == 0 { - return nil - } - return []string{fmt.Sprintf("%s mixes the config form (%s) with the settings form (%s): a module uses one form "+ - "or the other, never both; write %s, %s and ${config:} throughout (novox/hq ADR 0348 §1)", - m.Module, strings.Join(m.forms.config, ", "), strings.Join(m.forms.settings, ", "), - ConfigField, TakesConfigField)} -} - // configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing // unsaid. type configDeclaration struct { @@ -171,166 +150,6 @@ func (d SettingDeclaration) MarshalJSON() ([]byte, error) { Default: d.Default, Required: d.Required, Runtime: d.Runtime}) } -// configShaped is whether a declaration says anything of the config form's. -func configShaped(d SettingDeclaration) bool { - return d.Type != "" || d.Description != "" || len(d.Enum) > 0 || d.Minimum != nil || d.Maximum != nil || - d.Pattern != "" || d.Trusted || d.Predefined != "" || d.Required || d.Runtime -} - -// TypedSettingProblems is a declaration under `settings` that says what only `config` says: a value with a type, -// a description and its validation is declared in the config form (ADR 0348 §1, §4). -func TypedSettingProblems(m Manifest) []string { - var problems []string - for _, key := range sortedSettingKeys(m.Settings) { - if configShaped(m.Settings[key]) { - problems = append(problems, fmt.Sprintf("%s's setting %q says its type, description or validation "+ - "under %s: a value declared with those is declared under config, with ${config:} and "+ - "takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, SettingsField, SettingsField)) - } - } - return problems -} - -// ConfigProblems is every way a module in the config form declares its config wrongly, each said by name with -// what to do (ADR 0348 How it is checked, design 57 package K1). -func ConfigProblems(m Manifest) []string { - if !m.DeclaresConfig() { - return nil - } - var problems []string - placed := configPlaced(m) - fallsBack := fileFallsBack(m) - renamed := renamedTaken(m) - for _, key := range sortedSettingKeys(m.Settings) { - d := m.Settings[key] - say := func(format string, args ...any) { - problems = append(problems, fmt.Sprintf("%s's config value %q: ", m.Module, key)+fmt.Sprintf(format, args...)) - } - if !settingRef.MatchString("${setting:" + key + "}") { - say("not a usable key: lower-case letters, digits, dots, dashes and underscores") - continue - } - if meshWords[key] || accountFacts[key] { - say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key) - continue - } - if d.Kind != "" || d.Why != "" { - say("kind and why are the settings form's; under config a value says its type and its description " + - "(novox/hq ADR 0348 §1, §4)") - } - switch d.Type { - case ConfigNumber, ConfigString, ConfigBoolean: - case ConfigObject, ConfigList: - if !d.Trusted { - say("is %s and not trusted: an object or a list decides more than one value can say, so it is "+ - "trusted, \"trusted\": true, or split into numbers, strings and booleans (novox/hq ADR 0348 §4)", d.Type) - } - case "": - say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)") - default: - say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", d.Type) - } - if strings.TrimSpace(d.Description) == "" { - say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §4)") - } - switch { - case d.Required && d.Default != nil: - say("both a default and required: a required value is the operator's own and has no default; give " + - "one or the other (novox/hq ADR 0348 §4)") - case !d.Required && d.Default == nil: - say("neither a default nor required: give a default, or say \"required\": true for a value that is " + - "the operator's own (novox/hq ADR 0348 §4)") - } - if as := renamed[key]; as != "" && d.Required { - say("is laid into its file as %q, and only when it is set, so the file and its code fall back when "+ - "it is not: it is never required; give it the file's default, the empty value of its type "+ - "(novox/hq issue 751)", as) - } - for _, wrong := range validationProblems(d) { - say("%s", wrong) - } - if word := operatorsWord(key); word != "" && d.Default != nil && !fallsBack[key] { - say("a key naming %q is the operator's value: declare it \"required\": true, with no default, "+ - "unless a mergeable file takes it and no placeholder asks for it, where the file falls back "+ - "(novox/hq ADR 0348 §4, ADR 0112, issue 735)", word) - } - if d.Predefined != "" { - p, listed := m.predefined[d.Predefined] - switch { - case m.predefined == nil: - say("falls back to the predefined key %q, and the list of predefined keys (%s at the root of "+ - "the catalogue's repository) is not present where it is judged: a value falls back only to a "+ - "key on that list; judge it in the catalogue, or leave \"predefined\" out (novox/hq ADR 0348 §7)", - d.Predefined, PredefinedFile) - case !listed: - say("falls back to %q, which is not on the list of predefined keys: name a key on it, or leave "+ - "\"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined) - default: - if p.Type != d.Type { - say("falls back to the predefined key %q, which is a %s, and this value is a %s: a value falls "+ - "back only to a key of its own type (novox/hq ADR 0348 §4)", d.Predefined, p.Type, d.Type) - } - if p.Trusted && !d.Trusted { - say("falls back to the trusted predefined key %q and is not trusted itself: a value that falls "+ - "back to a trusted key is trusted, \"trusted\": true (novox/hq ADR 0348 §8)", d.Predefined) - } - } - } - // A template's text is not here yet. Resolve fills it from the build, WithTemplatesFrom from the tree; neither - // judges again itself, but WithTemplatesFrom parses its result again, and a resolved build is parsed again - // where it is registered, and this is judged then. - if !placed[key] && !d.Runtime && !m.Templated() { - say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+ - "\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, TakesConfigField) - } - } - used := settingKeysUsedBy(m) - for _, key := range sortedKeys(used) { - if _, declared := m.Settings[key]; !declared { - problems = append(problems, fmt.Sprintf("%s says ${config:%s} and declares no such value under %s: "+ - "declare %q with its type, description and a default or required (novox/hq ADR 0348 §4, §5)", - m.Module, key, ConfigField, key)) - } - } - problems = append(problems, configTakesProblems(m)...) - problems = append(problems, reflectsProblems(m)...) - return problems -} - -// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, and -// every value a mergeable file takes. -func configPlaced(m Manifest) map[string]bool { - placed := settingKeysUsedBy(m) - for _, r := range m.Resources { - for _, k := range takenKeyList(r) { - placed[k] = true - } - } - return placed -} - -// fileFallsBack is every value a mergeable file takes and no placeholder asks for. Unset, such a value is simply -// not laid over the file: the file's own content, or the module's code reading the file, falls back. So one named -// after the operator's value (a url, a user, an address) is not `required`, and its default is the file's: its -// value in the content, else the empty value of its type, never laid over the file (the operator's decision on -// issue 664, issue 735). A placeholder has no such fallback: its default would be filled in where the operator's -// value belongs (ADR 0112). -func fileFallsBack(m Manifest) map[string]bool { - asked := settingKeysUsedBy(m) - out := map[string]bool{} - for _, r := range m.Resources { - if how, _ := r["merge"].(string); how == "" { - continue - } - for _, k := range takenKeyList(r) { - if !asked[k] { - out[k] = true - } - } - } - return out -} - // renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751). func renamedTaken(m Manifest) map[string]string { out := map[string]string{} @@ -417,88 +236,3 @@ func ofType(v any, kind string) bool { } return false } - -// configTakesProblems is what ADR 0348 §11 adds for a module in the config form: an account fact in -// takes-config (the mesh's own words are refused for both forms by TakenSettingsProblems), and a mergeable file -// naming no keys where none of the module's does (where one does, TakenSettingsProblems names every one that -// does not). -func configTakesProblems(m Manifest) []string { - var problems, silent []string - naming := false - for _, r := range m.Resources { - if how, _ := r["merge"].(string); how == "" { - continue - } - if _, said := r[TakesSettingsField]; !said { - silent = append(silent, fmt.Sprint(r["id"])) - continue - } - naming = true - for _, k := range takenKeyList(r) { - _, declared := m.Settings[k] - switch { - case accountFacts[k]: - problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+ - "mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k, - TakesConfigField, k)) - case meshWords[k]: - // Refused for both forms by TakenSettingsProblems, in its words. - case !declared: - problems = append(problems, fmt.Sprintf("%s's %v names %q in %s and declares no such value under "+ - "%s: a value a file takes is declared like every other, with its type, description and a "+ - "default or required, or the config verb could never set it (novox/hq ADR 0348 §4, §11)", - m.Module, r["id"], k, TakesConfigField, ConfigField)) - } - } - } - if !naming { - for _, id := range silent { - problems = append(problems, fmt.Sprintf("%s's mergeable %s names no %s: a module that declares config "+ - "names the keys each mergeable file takes, an empty list for none (novox/hq ADR 0348 §11)", - m.Module, id, TakesConfigField)) - } - } - return problems -} - -// meshMadeIDs are the resources the mesh composes into a module's own set, which its `restart-on` may name -// without declaring them; meshMadePrefixes are those named after what they are for. -var ( - meshMadeIDs = []string{ComposedJailsID(), FilteringID(), CertificateID(), AuthorityID(), BusUsersID()} - meshMadePrefixes = []string{NeedID(""), SecretID(""), strings.TrimSuffix(GrantID("", ""), "-"), BoundID(""), ReceivedID(""), AccessID("/")} -) - -// reflectsProblems is a `reload-on` or `restart-on` naming a file the module does not declare (ADR 0348 §6): not -// one of its own resources, not one the mesh composes for it, and not another module's, which is named -// `.`. -func reflectsProblems(m Manifest) []string { - own := map[string]bool{} - for _, r := range m.Resources { - own[fmt.Sprint(r["id"])] = true - } - meshMade := func(id string) bool { - if slices.Contains(meshMadeIDs, id) { - return true - } - for _, p := range meshMadePrefixes { - if strings.HasPrefix(id, p) && len(id) > len(p) { - return true - } - } - return false - } - var problems []string - for _, r := range m.Resources { - for _, field := range []string{"reload-on", "restart-on"} { - for _, id := range stringsOf(r[field]) { - if strings.Contains(id, ".") || own[id] || meshMade(id) { - continue - } - problems = append(problems, fmt.Sprintf("%s's %v names %q in its %s, and %s declares no such "+ - "file: name a file of its own, or another module's as . (novox/hq ADR 0348 §6)", - m.Module, r["id"], id, field, m.Module)) - } - } - } - return problems -} diff --git a/go/catalogue/config_rename_test.go b/go/catalogue/config_rename_test.go index 35ed21a..f97ec6d 100644 --- a/go/catalogue/config_rename_test.go +++ b/go/catalogue/config_rename_test.go @@ -143,43 +143,7 @@ func TestARenamedEntryIsWrittenBackAsItWasSaid(t *testing.T) { if !strings.Contains(string(raw), `"takes-config":["position","theme",{"as":"NOTIFIER_URL","key":"url"}]`) { t.Fatalf("written back as %s", raw) } - if _, err := ParseManifest(raw); err != nil { + if _, err := decodeForTest(raw); err != nil { t.Fatalf("read again: %v", err) } } - -func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) { - entry := func(m map[string]any, e any) map[string]any { - resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e} - return m - } - refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`) - refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`) - refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key") - refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name") - refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads") - refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice") - refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice") - refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value") - - kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"}) - resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"} - refused(t, kept, `"NOTIFIER_URL"`, "protected") - - // Laid only when set, so the code's own fallback holds: never required. - required := renaming() - declaration(required, "url")["required"] = true - delete(declaration(required, "url"), "default") - refused(t, required, `"url"`, "only when it is set", "never required") -} - -func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) { - m := map[string]any{ - "module": "notifier", "version": "1", - "resources": []any{ - map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}", - "takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}}, - }, - } - refused(t, m, "notifier's rules", "config form") -} diff --git a/go/catalogue/config_test.go b/go/catalogue/config_test.go index 852d340..47efa8f 100644 --- a/go/catalogue/config_test.go +++ b/go/catalogue/config_test.go @@ -63,7 +63,7 @@ func parseRaw(t *testing.T, m map[string]any) (Manifest, error) { if err != nil { t.Fatal(err) } - return ParseManifest(raw) + return decodeForTest(raw) } // refused is that the manifest is refused with every one of these words. @@ -97,66 +97,6 @@ func TestAModuleInTheConfigFormRegisters(t *testing.T) { } } -// Rule 1: a value without type or description. -func TestConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) { - m := inConfigForm() - delete(declaration(m, "width"), "type") - refused(t, m, `notifier's config value "width"`, "no type") - m = inConfigForm() - declaration(m, "width")["type"] = "colour" - refused(t, m, `"width"`, `type "colour"`) - m = inConfigForm() - delete(declaration(m, "width"), "description") - refused(t, m, `"width"`, "no description") -} - -// Rule 2: a value with neither a default nor required. -func TestConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) { - m := inConfigForm() - delete(declaration(m, "width"), "default") - refused(t, m, `"width"`, "neither a default nor required") - m = inConfigForm() - declaration(m, "sender")["default"] = "noreply@example.org" - refused(t, m, `"sender"`, "both a default and required") -} - -// Rule 3: a default failing its validation. -func TestConfigRefusesADefaultFailingItsValidation(t *testing.T) { - for _, c := range []struct { - key string - value any - said string - }{ - {"font-size", 4, "below its minimum"}, - {"font-size", 41, "above its maximum"}, - {"font-size", "10", "not a number"}, - {"position", "bottom", "not one of"}, - {"theme", "Dark", "does not match"}, - } { - m := inConfigForm() - declaration(m, c.key)["default"] = c.value - refused(t, m, `"`+c.key+`"`, c.said) - } -} - -// Rule 4: an object or a list not trusted. -func TestConfigRefusesAnObjectOrListNotTrusted(t *testing.T) { - for _, kind := range []string{"object", "list"} { - m := inConfigForm() - def := any(map[string]any{"a": 1}) - if kind == "list" { - def = []any{"a"} - } - m["config"].(map[string]any)["rules"] = map[string]any{"type": kind, "description": "the rules, as " + kind, - "default": def, "runtime": true} - refused(t, m, `"rules"`, "is "+kind+" and not trusted") - m["config"].(map[string]any)["rules"].(map[string]any)["trusted"] = true - if _, err := parseRaw(t, m); err != nil { - t.Fatalf("a trusted %s is refused: %v", kind, err) - } - } -} - // withPredefined is the list of predefined keys the mesh published, for the length of a test. func withPredefined(t *testing.T, keys PredefinedList) { t.Helper() @@ -164,127 +104,6 @@ func withPredefined(t *testing.T, keys PredefinedList) { t.Cleanup(func() { ReplacePublished(was) }) } -// Rule 5: a predefined key not on the list, or of another type. Where no list is present, a value naming one is -// refused saying so. -func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) { - withPredefined(t, nil) - m := inConfigForm() - declaration(m, "font-size")["predefined"] = "font-size" - refused(t, m, `"font-size"`, "the list of predefined keys", "is not present") - - withPredefined(t, map[string]PredefinedKey{"font-size": {Type: "number"}, "accent": {Type: "string"}}) - if _, err := parseRaw(t, m); err != nil { - t.Fatalf("a value falling back to a listed key of its type is refused: %v", err) - } - m = inConfigForm() - declaration(m, "font-size")["predefined"] = "font-scale" - refused(t, m, `"font-size"`, `"font-scale"`, "not on the list") - m = inConfigForm() - declaration(m, "font-size")["predefined"] = "accent" - refused(t, m, `"font-size"`, `"accent"`, "is a string") -} - -// Rule 6: an untrusted value falling back to a trusted predefined key. -func TestConfigRefusesAnUntrustedValueFallingBackToATrustedKey(t *testing.T) { - withPredefined(t, nil) - m := inConfigForm() - declaration(m, "theme")["predefined"] = "theme" - refused(t, m, `"theme"`, "is not present") - - withPredefined(t, map[string]PredefinedKey{"theme": {Type: "string", Trusted: true}}) - refused(t, m, `"theme"`, "trusted", "is not") - declaration(m, "theme")["trusted"] = true - if _, err := parseRaw(t, m); err != nil { - t.Fatalf("a trusted value falling back to a trusted key is refused: %v", err) - } -} - -// Rule 7: a value neither placed in a template nor read at runtime. -func TestConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) { - m := inConfigForm() - delete(declaration(m, "history"), "runtime") - refused(t, m, `"history"`, "${config:history}", `"runtime": true`) - // Taken by a mergeable file is placed. - m = inConfigForm() - if _, err := parseRaw(t, m); err != nil { - t.Fatal(err) - } -} - -// Rule 8: a placeholder naming an undeclared value. -func TestConfigRefusesAPlaceholderNamingAnUndeclaredValue(t *testing.T) { - m := inConfigForm() - resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${config:width}\n" + - "from = ${config:sender}\ncolour = ${config:colour}\n" - refused(t, m, "${config:colour}", "declares no such value") -} - -// Rule 9: a reload-on or restart-on naming a file the module does not declare. -func TestConfigRefusesAReloadOrRestartOnNamingAnUndeclaredFile(t *testing.T) { - m := inConfigForm() - resourceOf(m, "notifier")["reload-on"] = []any{"configuration", "dunstrc"} - refused(t, m, "reload-on", `"dunstrc"`, "declares no such") - m = inConfigForm() - resourceOf(m, "notifier")["restart-on"] = []any{"rule"} - refused(t, m, "restart-on", `"rule"`) - // Another module's file, and one the mesh makes for the module, are not the module's to declare. - m = inConfigForm() - resourceOf(m, "notifier")["restart-on"] = []any{"rules", "resolver.fact-zones", "secret-mail", "composed-jails"} - if _, err := parseRaw(t, m); err != nil { - t.Fatalf("refused: %v", err) - } -} - -// Rule 10: a moved module's mergeable file not naming its keys under takes-config. -func TestConfigRefusesAMergeableFileNotNamingItsKeys(t *testing.T) { - m := inConfigForm() - delete(resourceOf(m, "rules"), "takes-config") - // position and theme are then placed nowhere; say they are read at runtime so only the file's rule speaks. - declaration(m, "position")["runtime"] = true - declaration(m, "theme")["runtime"] = true - refused(t, m, "rules", "names no takes-config") -} - -// Rule 11: a mesh word, puid or pgid in takes-config. -func TestConfigRefusesAMeshWordOrAnAccountFactInTakesConfig(t *testing.T) { - for _, word := range []string{"ports", "expose", "reach", "endpoints", "places", "accesses", "networks", "puid", "pgid"} { - m := inConfigForm() - resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", word} - refused(t, m, `"`+word+`"`, "takes-config") - } -} - -// A module uses one form or the other, never both. -func TestAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) { - m := inConfigForm() - resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${setting:width}\nfrom = ${config:sender}\n" - refused(t, m, "mixes", "${setting:", "config") - - m = inConfigForm() - delete(resourceOf(m, "rules"), "takes-config") - resourceOf(m, "rules")["takes-settings"] = []any{"position", "theme"} - refused(t, m, "mixes", "takes-settings") - - m = inConfigForm() - m["settings"] = map[string]any{"colour": map[string]any{"kind": "preference", "default": "red", "why": "red"}} - refused(t, m, "mixes", "settings") -} - -// The settings form keeps its own declaration; the config form's has no kind or why. -func TestEachFormKeepsItsOwnDeclaration(t *testing.T) { - m := inConfigForm() - declaration(m, "width")["why"] = "fits a title" - refused(t, m, `"width"`, "why") - - err := parsed(t, Manifest{Module: "notifier", - Settings: map[string]SettingDeclaration{"width": {Kind: KindPreference, Default: float64(250), - Why: "fits a title", Type: "number"}}, - Resources: []map[string]any{{"id": "c", "type": "file", "path": "/x", "content": "${setting:width}"}}}) - if err == nil || !strings.Contains(err.Error(), "under config") { - t.Fatalf("a typed value under settings: %v", err) - } -} - // What the mesh holds is what was written: a module in the config form is written back in it, and read again // the same. func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) { @@ -306,7 +125,7 @@ func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) { t.Fatalf("written back with %s: %s", unsaid, raw) } } - again, err := ParseManifest(raw) + again, err := decodeForTest(raw) if err != nil { t.Fatal(err) } @@ -365,7 +184,7 @@ const configDunst = `{"module": "dunst", "version": "1", func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) { composed := func(manifest string, layers []Layer) []byte { t.Helper() - m, err := ParseManifest([]byte(manifest)) + m, err := decodeForTest([]byte(manifest)) if err != nil { t.Fatal(err) } @@ -404,14 +223,6 @@ func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing. } } -// A key a mergeable file takes is a config value like every other: one not declared under config is refused by -// name (ADR 0348 §4, review of mesh-controller#320). -func TestConfigRefusesATakesConfigKeyNotDeclared(t *testing.T) { - m := inConfigForm() - resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "urgency"} - refused(t, m, `"urgency"`, "takes-config", "declares no such value under config") -} - // A required value nothing sets is refused at composition in the config form's words: ${config:} and the // config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged. func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { @@ -466,36 +277,6 @@ func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { } } -// A value named after the operator's own (a url, a user, an address) is `required` where nothing falls back when -// it is unset; where a mergeable file takes it and no placeholder asks for it, the file's own content (or the -// module's code reading the file) falls back, so it keeps a default and stays optional, as it is today: the -// operator's decision on issue 664's second question (issue 735). -func TestAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(t *testing.T) { - m := inConfigForm() - m["config"].(map[string]any)["url"] = map[string]any{"type": "string", - "description": "the address the notifier posts to, a URL; empty for the one the file's software picks", - "default": ""} - resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "url"} - if _, err := parseRaw(t, m); err != nil { - t.Fatalf("a taken value named after the operator's, with a default, was refused: %v", err) - } - - // A placeholder asking for it has no fallback but the default, which would put a value in the definition - // where the operator's belongs (ADR 0112). - asked := inConfigForm() - asked["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", "default": ""} - resourceOf(asked, "rules")["takes-config"] = []any{"position", "theme", "url"} - resourceOf(asked, "configuration")["content"] = "font = Inter ${config:font-size}\nwidth = ${config:width}\n" + - "from = ${config:sender}\npost = ${config:url}\n" - refused(t, asked, `"url"`, `a key naming "url" is the operator's value`) - - // Read by the module's code alone, nothing in the definition says it falls back. - runtime := inConfigForm() - runtime["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", - "default": "", "runtime": true} - refused(t, runtime, `"url"`, `a key naming "url" is the operator's value`) -} - // 1658/AC1: a value whose only placement is ${config:key} in an item's text is placed. func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) { m := map[string]any{ @@ -509,15 +290,3 @@ func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) { t.Fatalf("a value placed only in an item's text is refused: %v", err) } } - -// 1658/AC2: a declared value placed nowhere, with an item that does not say it, is still refused. -func TestConfigStillRefusesAValueNoItemPlaces(t *testing.T) { - m := map[string]any{ - "module": "m", "version": "1", - "config": map[string]any{ - "k": map[string]any{"type": "string", "description": "a word nothing says", "default": "x"}, - }, - "items": []any{map[string]any{"collection": "a", "content": "k = fixed\n"}}, - } - refused(t, m, `"k"`, "placed nowhere") -} diff --git a/go/catalogue/consumer_into_serves.go b/go/catalogue/consumer_into_serves.go index e5d4791..441a77d 100644 --- a/go/catalogue/consumer_into_serves.go +++ b/go/catalogue/consumer_into_serves.go @@ -124,55 +124,6 @@ func asDNSLabel(as string) string { return strings.ReplaceAll(as, "_", "-") } -// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not -// have, when the definition is parsed rather than when a consumer is resolved. -// -// A provision nobody consumes yet still has its rule read: a definition that would be refused the -// first time somebody required it is a definition that is wrong now. -func CheckServes(m Manifest) []string { - var problems []string - for _, provision := range sortedServes(m.Serves) { - for _, key := range sortedAnyKeys(m.Serves[provision]) { - text, ok := m.Serves[provision][key].(string) - if !ok { - // A provider's own name is filled only in a value of its own (novox/hq ADR 0346): one - // nested in a list or an object would be served as the placeholder, unsaid. - problems = append(problems, nestedOwnNames(m, provision, key, m.Serves[provision][key])...) - continue - } - // A probe identity, because what is checked is the shape of the statement and not - // what any consumer is called. - if _, err := consumerInto(text, "mesh_node_module"); err != nil { - problems = append(problems, fmt.Sprintf( - "%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err)) - } - // And the provider's own names it may serve (novox/hq ADR 0346). - problems = append(problems, checkServedOwnNames(m, provision, key, text)...) - // A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own - // bound has to keep the identity inside one (ADR 0225). - if strings.Contains(text, "${consumer:as:dns}") { - if b := m.IdentityBoundOf(provision); !b.Bounded() || b.Max > dnsLabelLimit { - problems = append(problems, fmt.Sprintf( - "%s serves %s's consumers their identity as a DNS label in %q, and bounds "+ - "that identity at %s: a label keeps %d — state an `identity` of at most %d", - m.Module, provision, key, boundWords(b), dnsLabelLimit, dnsLabelLimit)) - } - } - } - } - return problems -} - -// dnsLabelLimit is the longest DNS label (RFC 1035 §2.3.4). -const dnsLabelLimit = 63 - -func boundWords(b IdentityBound) string { - if !b.Bounded() { - return "nothing" - } - return fmt.Sprintf("%d", b.Max) -} - func sortedServes(serves map[string]map[string]any) []string { out := make([]string, 0, len(serves)) for k := range serves { diff --git a/go/catalogue/consumes_start_test.go b/go/catalogue/consumes_start_test.go index 3374792..fc3ed1b 100644 --- a/go/catalogue/consumes_start_test.go +++ b/go/catalogue/consumes_start_test.go @@ -10,7 +10,7 @@ import ( func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4 for _, key := range []string{"from", "start", "deliver"} { raw := `{"module":"audit","version":"1","consumes":[{"event":"shop.order.placed","` + key + `":"beginning"}]}` - _, err := ParseManifest([]byte(raw)) + _, err := decodeForTest([]byte(raw)) if err == nil { t.Fatalf("a consumes entry with %q was accepted", key) } @@ -18,7 +18,7 @@ func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4 t.Errorf("the refusal for %q does not name the key and the field: %v", key, err) } } - if _, err := ParseManifest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil { + if _, err := decodeForTest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil { t.Errorf("a plain consumes entry was refused: %v", err) } } diff --git a/go/catalogue/contributes_test.go b/go/catalogue/contributes_test.go index beb6ab6..19db70d 100644 --- a/go/catalogue/contributes_test.go +++ b/go/catalogue/contributes_test.go @@ -218,30 +218,6 @@ func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) { } } -func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) { - // It would create a file nobody ever writes to, on a machine where nothing asked for it. - _, err := ParseManifest([]byte(`{"module":"traefik","version":"1", - "receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`)) - if err == nil { - t.Fatal("a module received contributions to something it does not provide") - } - if !strings.Contains(err.Error(), "does not provide") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - -func TestAnEmptyContributionIsRefused(t *testing.T) { - // Either a mistake or a requirement written the long way round, and both are better said. - _, err := ParseManifest([]byte(`{"module":"board","version":"1", - "contributes":{"reverse-proxy":{}}}`)) - if err == nil { - t.Fatal("a module contributed nothing and was accepted") - } - if !strings.Contains(err.Error(), "require it") { - t.Fatalf("the refusal does not say what to do instead: %v", err) - } -} - // A provision answered from anywhere in the mesh has consumers on other machines, and the // provider has to know who they are. Contributions were node-local until this, which meant the // one case that most needed them was the one they did not reach. diff --git a/go/catalogue/contribution_dependencies_test.go b/go/catalogue/contribution_dependencies_test.go index 91e9bc0..dc8cafd 100644 --- a/go/catalogue/contribution_dependencies_test.go +++ b/go/catalogue/contribution_dependencies_test.go @@ -113,24 +113,3 @@ func TestAPackageDeclaredPresentAndAbsentIsRefusedNamingBoth(t *testing.T) { t.Errorf("an unrelated assignment is refused for a contradiction already there: %v", err) } } - -func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) { - for _, moment := range powerMoments { - m := mod("laptop", nil, nil, nil) - m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}} - if got := DependsOn(nil, m); !reflect.DeepEqual(got, []string{PowerSeat}) { - t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat) - } - if p := m.shellProblems(); len(p) != 0 { - t.Errorf("code for %s is refused: %v", moment, p) - } - } - m := mod("laptop", nil, nil, nil) - m.Shell = []ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}} - if p := m.shellProblems(); len(p) == 0 { - t.Error("code for a moment that does not exist was accepted") - } - if s, ok := SeatNamed(PowerSeat); !ok || s.Scope != ScopeNode { - t.Errorf("%s is not a node seat of the mesh's own: %+v %v", PowerSeat, s, ok) - } -} diff --git a/go/catalogue/core_credentials_test.go b/go/catalogue/core_credentials_test.go index 1c123f3..7e09a7d 100644 --- a/go/catalogue/core_credentials_test.go +++ b/go/catalogue/core_credentials_test.go @@ -33,7 +33,7 @@ const provisionerClient = `{"module":"mesh-provisioner-client","version":"1", func clientOn(t *testing.T) Resolution { t.Helper() - m, err := ParseManifest([]byte(provisionerClient)) + m, err := decodeForTest([]byte(provisionerClient)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/cron.go b/go/catalogue/cron.go index fbb6d29..9fa7ce1 100644 --- a/go/catalogue/cron.go +++ b/go/catalogue/cron.go @@ -13,80 +13,3 @@ package catalogue // The ordinary five fields — minute, hour, day-of-month, month, day-of-week — with `*`, lists // (`,`), ranges (`-`) and steps (`/`). No names (jan, mon): a numeric cron is what the host // evaluates, and refusing a name here is refusing it near whoever wrote it. - -import ( - "fmt" - "strconv" - "strings" -) - -// validateCron reports why a string is not a five-field cron expression, or nil if it is one. -func validateCron(expr string) error { - fields := strings.Fields(expr) - if len(fields) != 5 { - return fmt.Errorf( - "a schedule is a five-field cron expression (minute hour day-of-month month "+ - "day-of-week), and this has %d field(s)", len(fields)) - } - bounds := []struct { - name string - min, max int - }{ - {"minute", 0, 59}, - {"hour", 0, 23}, - {"day-of-month", 1, 31}, - {"month", 1, 12}, - {"day-of-week", 0, 7}, // 0 and 7 are both Sunday, the convention every cron keeps - } - for i, b := range bounds { - if err := validateCronField(fields[i], b.min, b.max); err != nil { - return fmt.Errorf("%s field: %w", b.name, err) - } - } - return nil -} - -// validateCronField checks one field's elements are within range and well-formed. -func validateCronField(spec string, min, max int) error { - if spec == "" { - return fmt.Errorf("is empty") - } - for _, part := range strings.Split(spec, ",") { - if part == "" { - return fmt.Errorf("%q has an empty element between commas", spec) - } - - rangePart := part - if slash := strings.IndexByte(part, '/'); slash >= 0 { - rangePart = part[:slash] - n, err := strconv.Atoi(part[slash+1:]) - if err != nil || n < 1 { - return fmt.Errorf("step in %q is not a positive number", part) - } - } - - switch { - case rangePart == "*": - // Any value; nothing to bound. - case strings.IndexByte(rangePart, '-') >= 0: - dash := strings.IndexByte(rangePart, '-') - lo, errLo := strconv.Atoi(rangePart[:dash]) - hi, errHi := strconv.Atoi(rangePart[dash+1:]) - if errLo != nil || errHi != nil { - return fmt.Errorf("range %q is not two numbers", rangePart) - } - if lo < min || hi > max || lo > hi { - return fmt.Errorf("range %q is outside the allowed %d-%d", part, min, max) - } - default: - v, err := strconv.Atoi(rangePart) - if err != nil { - return fmt.Errorf("%q is not a number", rangePart) - } - if v < min || v > max { - return fmt.Errorf("%q is outside the allowed range %d-%d", part, min, max) - } - } - } - return nil -} diff --git a/go/catalogue/data.go b/go/catalogue/data.go index 5a43203..86ddf79 100644 --- a/go/catalogue/data.go +++ b/go/catalogue/data.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "regexp" - "sort" "strconv" "strings" "time" @@ -305,182 +304,6 @@ func keepsByClass(class string) bool { return class == ClassIrreplaceable || class == ClassValuable || class == ClassRebuildable } -// dataID is what an item's id may be: it is a token in a condition's key and a word on a line. -var dataID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) - -// dataPathRef is an item's path: one of the module's directories or accesses, and optionally a path -// beneath it. -var dataPathRef = regexp.MustCompile(`^\$\{(dir|access):([a-z0-9][a-z0-9-]*)\}(/[^\s$]*)?$`) - -// dataProblems is what is wrong with the `data` section itself, from the manifest alone: judged at -// registration as every other per-manifest problem is. Whether a module declares what it should is -// the catalogue check's (DataProblems), because a module already running was written before it. -func (m Manifest) dataProblems() []string { - if m.Data == nil { - return nil - } - var problems []string - say := func(format string, args ...any) { - problems = append(problems, fmt.Sprintf("%s's data: ", m.Module)+fmt.Sprintf(format, args...)) - } - dirs := map[string]bool{} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "directory" { - dirs[fmt.Sprint(r["id"])] = true - } - } - accesses := map[string]bool{} - for _, a := range m.Accesses { - if a.ID != "" { - accesses[a.ID] = true - } - } - ids := map[string]DataItem{} - paths := map[string]string{} - for i, it := range m.Data.Own { - label := it.ID - if label == "" { - label = fmt.Sprintf("item %d", i+1) - } - if !dataID.MatchString(it.ID) { - say("%s has no usable id: lower-case letters, digits and dashes", label) - } else if _, twice := ids[it.ID]; twice { - say("%s is declared twice", it.ID) - } - ids[it.ID] = it - ref := dataPathRef.FindStringSubmatch(it.Path) - switch { - case ref == nil: - say("%s's path %q is not one of the module's directories or accesses: ${dir:} or ${access:}, "+ - "or a path beneath one (a definition names no machine path, novox/hq ADR 0112)", label, it.Path) - case ref[1] == "dir" && !dirs[ref[2]]: - say("%s's path names ${dir:%s}, and %s declares no directory %q", label, ref[2], m.Module, ref[2]) - case ref[1] == "access" && !accesses[ref[2]]: - say("%s's path names ${access:%s}, and %s declares no access %q", label, ref[2], m.Module, ref[2]) - case strings.Contains(it.Path, ".."): - say("%s's path %q climbs out of its directory", label, it.Path) - } - if other, twice := paths[it.Path]; twice && it.Path != "" { - say("%s and %s name the same path %s", other, label, it.Path) - } - paths[it.Path] = label - switch it.Class { - case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache: - case ClassNone: - say("%s is class none, which only a provision keeping nothing of its consumers' is; own data "+ - "that is disposable is cache", label) - default: - say("%s's class %q is not one the mesh protects by: irreplaceable, valuable, rebuildable or cache", - label, it.Class) - } - if it.Class == ClassIrreplaceable && !it.BackedUp() && strings.TrimSpace(it.Redundancy) == "" { - say("%s is irreplaceable and is neither backed up nor said to be protected by redundancy; the only "+ - "copy of something is protected one way or the other (novox/hq ADR 0233) — copy it, dump it into "+ - "another item, or say `redundancy` with why that is enough", label) - } - if it.Class == ClassCache && it.Backup != nil && !it.Backup.None { - say("%s is a cache and asks to be backed up; a cache is disposable, or it is not a cache", label) - } - if it.Class == ClassCache && it.Redundancy != "" { - say("%s is a cache and says it is protected by redundancy; a cache is not protected", label) - } - switch it.Measure { - case "", MeasureWalk, MeasureDataset, MeasureShallow: - default: - say("%s's measure %q is walk, dataset or shallow", label, it.Measure) - } - if _, err := ParseDataDuration(it.Within); err != nil { - say("%s's within: %v", label, err) - } - if _, err := ParseDataDuration(it.Active); err != nil { - say("%s's active: %v", label, err) - } - if it.Within != "" && !it.BackedUp() { - say("%s states within, and is not backed up", label) - } - if it.Active != "" && !Watched(it.Class) { - say("%s is %s and expects writes; only irreplaceable and valuable data is watched", label, it.Class) - } - } - // Dumps go into an item of the same module, declared, and not into themselves. - for _, it := range m.Data.Own { - if it.Backup == nil || it.Backup.Copy || it.Backup.None { - continue - } - switch into, ok := ids[it.Backup.Into]; { - case strings.TrimSpace(it.Backup.Dump) == "": - say("%s's backup names no dump command", it.ID) - case it.Backup.Into == "": - say("%s's dump says no item it writes into", it.ID) - case !ok: - say("%s's dump writes into %q, which is not one of the module's data items", it.ID, it.Backup.Into) - case into.ID == it.ID: - say("%s's dump writes into itself; a dump is copied from where it lands", it.ID) - case into.Class == ClassCache: - say("%s's dump writes into %s, a cache; what is copied must not be disposable", it.ID, into.ID) - } - if it.Backup.Dump != "" { - declared := map[string]string{} - for d := range dirs { - declared[d] = "" - } - if _, err := dirFill(it.Backup.Dump, declared, m.Module); err != nil { - say("%s's dump: %v", it.ID, err) - } - } - } - provided := map[string]bool{} - for _, o := range m.Provides { - provided[o.Name] = true - } - wants := map[string]bool{} - for _, w := range m.Wants() { - wants[w] = true - } - for _, provision := range sortedKeys(m.Data.Consumers) { - c := m.Data.Consumers[provision] - if !provided[provision] { - say("says what it keeps for the consumers of %q, which it does not provide", provision) - } - switch c.Class { - case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache, ClassNone: - default: - say("its consumers' %s is class %q; one of irreplaceable, valuable, rebuildable, cache or none", provision, c.Class) - } - switch { - case c.Class == ClassNone && c.In != "": - say("its consumers' %s keeps nothing and says where it is kept (%s)", provision, c.In) - case keepsByClass(c.Class) && c.In == "": - say("its consumers' %s is %s and says nowhere it lives: `in` names one of the module's items, or a "+ - "provision it requires", provision, c.Class) - case c.In != "": - if own, ok := ids[c.In]; ok { - if c.Class == ClassIrreplaceable && !own.BackedUp() && own.Redundancy == "" { - say("its consumers' %s is irreplaceable and lives in %s, which is not protected", provision, c.In) - } - if classRank[own.Class] < classRank[c.Class] { - say("its consumers' %s is %s and lives in %s, which is only %s", provision, c.Class, c.In, own.Class) - } - } else if !wants[c.In] { - say("its consumers' %s lives in %q, which is neither one of its data items nor a provision it "+ - "requires", provision, c.In) - } - } - } - for _, provision := range sortedKeys(m.Data.KeptBy) { - k := m.Data.KeptBy[provision] - if !wants[provision] { - say("says it keeps data with the provider of %q, which it does not require", provision) - } - switch k.Class { - case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache: - default: - say("what it keeps with %s is class %q; one of irreplaceable, valuable, rebuildable or cache", provision, k.Class) - } - } - return problems -} - // KeepsConsumerData is whether this module, providing a provision, keeps what each consumer writes // there (novox/hq ADR 0232, ADR 0233): whether a consumer bound to it is bound to its data. // @@ -596,196 +419,9 @@ func (m Manifest) allContributions() []SeatContribution { // --- the catalogue check ------------------------------------------------------------------------ -// DataProblems is what the catalogue check refuses about the data a module declares (novox/hq ADR -// 0233), judged over the manifests given together: -// -// - a provider that grants a provision says what it keeps for that provision's consumers; -// - nobody says it on the offer any more (`keeps-consumer-data`): the data section is the one place; -// - nobody writes a backup line by hand: it is derived from the data section; -// - a directory a container writes, mounted whole, is a data item of some class; -// - consumer data said to live in a required provision lives where that provision's provider keeps -// its consumers' data, as preciously, when the provider is given; -// - data a consumer keeps with a provider as irreplaceable is protected there, when the provider is -// given. -// -// **The check's, not registration's**, as ADR 0214's backup rule was: a module already on the shelf -// was written before the rule, and refusing it there would refuse the very providers whose data the -// rule protects. Each module meets it where it is written. -func DataProblems(shelf Shelf) []string { - var problems []string - for _, name := range shelfOrder(shelf) { - m := shelf[name] - for _, provision := range sortedKeys(m.Grants) { - if _, said := m.ConsumerDataOf(provision); !said { - problems = append(problems, fmt.Sprintf( - "%s grants %s and does not say what it keeps for its consumers: data.consumers.%s with a "+ - "class — irreplaceable, valuable, rebuildable, cache, or none (novox/hq ADR 0233)", name, provision, provision)) - } - } - for _, o := range m.Provides { - if o.KeepsConsumerData != nil { - problems = append(problems, fmt.Sprintf( - "%s says keeps-consumer-data on its offer of %s; it is said once, in data.consumers.%s, with a "+ - "class (novox/hq ADR 0233)", name, o.Name, o.Name)) - } - } - for i, c := range m.Contributions { - if s, known := SeatNamed(c.Seat); known && s.Name == BackupSeat { - problems = append(problems, fmt.Sprintf( - "%s's contribution %d is a backup line written by hand; backups are derived from the data "+ - "section — declare the data, with its class and how it is protected (novox/hq ADR 0233)", name, i+1)) - } - } - for _, dir := range writtenDirectories(m) { - if !coversDirectory(m, dir) { - problems = append(problems, fmt.Sprintf( - "%s mounts its directory %q into a container to be written, and declares no data in it: "+ - "data.own with a class — cache if it is disposable (novox/hq ADR 0233)", name, dir)) - } - } - if m.Data == nil { - continue - } - for _, provision := range sortedKeys(m.Data.Consumers) { - c := m.Data.Consumers[provision] - if c.In == "" { - continue - } - if _, own := m.DataItem(c.In); own { - continue - } - for _, pname := range shelfOrder(shelf) { - p := shelf[pname] - pc, said := p.ConsumerDataOf(c.In) - if !said || !providesName(p, c.In) { - continue - } - if classRank[pc.Class] < classRank[c.Class] { - problems = append(problems, fmt.Sprintf( - "%s keeps its consumers' %s, %s, in %s — and %s keeps its consumers' %s as %s, "+ - "so it is not protected as %s", name, provision, c.Class, c.In, pname, c.In, pc.Class, c.Class)) - } - } - } - for _, provision := range sortedKeys(m.Data.KeptBy) { - k := m.Data.KeptBy[provision] - if k.Class != ClassIrreplaceable { - continue - } - for _, pname := range shelfOrder(shelf) { - p := shelf[pname] - pc, said := p.ConsumerDataOf(provision) - if !said || !providesName(p, provision) { - continue - } - if !keepsByClass(pc.Class) { - problems = append(problems, fmt.Sprintf( - "%s keeps irreplaceable data with %s, and %s keeps its consumers' %s as %s — nothing of it is "+ - "protected there", name, provision, pname, provision, pc.Class)) - continue - } - if in, own := p.DataItem(pc.In); own && !in.BackedUp() && in.Redundancy == "" { - problems = append(problems, fmt.Sprintf( - "%s keeps irreplaceable data with %s, and %s keeps it in %s, which is neither backed up nor "+ - "on declared redundancy", name, provision, pname, pc.In)) - } - } - } - } - return problems -} - -func providesName(m Manifest, provision string) bool { - for _, o := range m.Provides { - if o.Name == provision { - return true - } - } - return false -} - -// writtenDirectories are the module's directories a container mounts whole and may write: a volume -// `${dir:}:` without `:ro`, or a directory stated by an absolute path mounted the same -// way. A file beneath a directory, or a read-only mount, is configuration the mesh wrote. -func writtenDirectories(m Manifest) []string { - absolute := map[string]string{} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "directory" { - continue - } - if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") { - absolute[strings.TrimRight(p, "/")] = fmt.Sprint(r["id"]) - } - } - seen := map[string]bool{} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "container" { - continue - } - vols, _ := r["volumes"].([]any) - for _, v := range vols { - s, _ := v.(string) - mount := volumeMount.FindStringSubmatch(s) - if mount == nil || volumeReadOnly(mount[3]) { - continue - } - src := strings.TrimRight(mount[1], "/") - if ref := dirPlain.FindStringSubmatch(src); ref != nil { - seen[ref[1]] = true - } else if id, ok := absolute[src]; ok { - seen[id] = true - } - } - } - out := make([]string, 0, len(seen)) - for id := range seen { - out = append(out, id) - } - sort.Strings(out) - return out -} - // volumeMount is a container's volume, `:[:]`, its source possibly a // `${dir:}` — whose own colon is not the separator. var volumeMount = regexp.MustCompile(`^(\$\{(?:dir|access):[a-z0-9][a-z0-9-]*\}[^:]*|[^:]+):([^:]+)(?::(.*))?$`) -// volumeReadOnly is whether a volume's options mount it read-only. -func volumeReadOnly(options string) bool { - for _, o := range strings.Split(options, ",") { - if strings.TrimSpace(o) == "ro" { - return true - } - } - return false -} - // dirPlain is a whole directory, `${dir:}` and nothing after it. var dirPlain = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9-]*)\}$`) - -// coversDirectory is whether a data item names the directory, a path within it, or a directory it -// is placed beneath. -func coversDirectory(m Manifest, dir string) bool { - parents := map[string]string{} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "directory" { - continue - } - if p, ok := r["path"].(string); ok { - if ref := dirRef.FindStringSubmatch(p); ref != nil && strings.HasPrefix(p, "${dir:") { - parents[fmt.Sprint(r["id"])] = ref[1] - } - } - } - for _, it := range m.DataItems() { - ref := dataPathRef.FindStringSubmatch(it.Path) - if ref == nil || ref[1] != "dir" { - continue - } - for d, hops := dir, 0; d != "" && hops < 4; d, hops = parents[d], hops+1 { - if ref[2] == d { - return true - } - } - } - return false -} diff --git a/go/catalogue/data_test.go b/go/catalogue/data_test.go index a013e9c..a3e98d7 100644 --- a/go/catalogue/data_test.go +++ b/go/catalogue/data_test.go @@ -2,7 +2,6 @@ package catalogue import ( "slices" - "strings" "testing" ) @@ -24,111 +23,13 @@ const declaredStore = `{"module":"pg","version":"1", func mustParse(t *testing.T, raw string) Manifest { t.Helper() - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err != nil { t.Fatalf("refused: %v", err) } return m } -func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) { - m := mustParse(t, declaredStore) - if problems := DataProblems(Shelf{"pg": m}); len(problems) > 0 { - t.Fatalf("a store declaring its data was refused: %v", problems) - } - if !m.KeepsConsumerData("postgres-database") { - t.Fatal("an irreplaceable consumer class does not keep the consumer's data") - } - if it, _ := m.DataItem("store"); !it.BackedUp() || it.BackupWithin() != DefaultBackupWithin || it.ActiveWithin().Hours() != 24 { - t.Fatalf("the store item reads %+v", it) - } - if it, _ := m.DataItem("dumps"); it.BackedUp() { - t.Fatal("a rebuildable item that says none is backed up") - } -} - -// Every rule of the section itself, refused at parse in the words of the module. -func TestTheDataSectionIsRefusedWhereItIsWrong(t *testing.T) { - for _, c := range []struct{ name, data, want string }{ - {"a class it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"precious"}]}`, "not one the mesh protects by"}, - {"an access it does not have", `{"own":[{"id":"a","path":"${access:media}","class":"irreplaceable","redundancy":"an array"}]}`, "declares no access"}, - {"irreplaceable and protected by nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":"none"}]}`, "protected one way or the other"}, - {"a cache on redundancy", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","redundancy":"an array"}]}`, "a cache is not protected"}, - {"kept with something not required", `{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}`, "does not require"}, - {"a measure it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","measure":"du"}]}`, "walk, dataset or shallow"}, - {"a cache expecting writes", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","active":"1d"}]}`, "only irreplaceable and valuable"}, - {"a machine path", `{"own":[{"id":"a","path":"/srv/a","class":"cache"}]}`, "names no machine path"}, - {"a directory it does not declare", `{"own":[{"id":"a","path":"${dir:nowhere}","class":"cache"}]}`, "declares no directory"}, - {"a cache backed up", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","backup":"copy"}]}`, "disposable"}, - {"a dump into nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"b"}}]}`, "not one of the module's data items"}, - {"a dump into itself", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"a"}}]}`, "into itself"}, - {"a bad duration", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","within":"soon"}]}`, "not a duration"}, - {"the same id twice", `{"own":[{"id":"a","path":"${dir:d}","class":"cache"},{"id":"a","path":"${dir:d}/x","class":"cache"}]}`, "declared twice"}, - {"own data of class none", `{"own":[{"id":"a","path":"${dir:d}","class":"none"}]}`, "only a provision"}, - {"consumers of something not provided", `{"consumers":{"s3-bucket":{"class":"cache"}}}`, "does not provide"}, - {"consumer data that lives nowhere", `{"consumers":{"q":{"class":"irreplaceable"}}}`, "says nowhere it lives"}, - {"consumer data in an item less precious", `{"own":[{"id":"a","path":"${dir:d}","class":"rebuildable"}],"consumers":{"q":{"class":"irreplaceable","in":"a"}}}`, "which is only rebuildable"}, - {"consumer data in something unknown", `{"consumers":{"q":{"class":"rebuildable","in":"elsewhere"}}}`, "neither one of its data items"}, - } { - raw := `{"module":"m","version":"1","provides":["q"],"resources":[{"id":"d","type":"directory","mode":"0700"}],"data":` + c.data + `}` - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: %v, want %q", c.name, err, c.want) - } - } -} - -// The catalogue check: a granting provider says what it keeps, says it in one place, writes no backup -// line by hand, and every directory a container writes is declared (novox/hq ADR 0233). -func TestTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) { - unsaid := mustParse(t, `{"module":"q","version":"1","provides":[{"name":"queue","scope":"mesh"}], - "grants":{"queue":"${dir:g}"},"resources":[{"id":"g","type":"directory","mode":"0700"}]}`) - onTheOffer := mustParse(t, `{"module":"r","version":"1","provides":[{"name":"resolver","scope":"mesh","keeps-consumer-data":false}]}`) - byHand := mustParse(t, `{"module":"h","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}], - "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:d}"}]}`) - writes := mustParse(t, `{"module":"w","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}, - {"id":"c","type":"directory","mode":"0700"}, - {"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data","${dir:c}:/etc/app:ro","${dir:d}/f:/f"]}]}`) - problems := strings.Join(DataProblems(Shelf{"q": unsaid, "r": onTheOffer, "h": byHand, "w": writes}), "\n") - for _, want := range []string{ - "q grants queue and does not say what it keeps", - "r says keeps-consumer-data on its offer", - "h's contribution 1 is a backup line written by hand", - `w mounts its directory "d" into a container to be written`, - } { - if !strings.Contains(problems, want) { - t.Errorf("the check does not say %q:\n%s", want, problems) - } - } - if strings.Contains(problems, `"c"`) { - t.Errorf("a read-only mount was taken for written data:\n%s", problems) - } - // The same module declaring the directory, as a cache, passes. - declared := mustParse(t, `{"module":"w","version":"1","data":{"own":[{"id":"d","path":"${dir:d}","class":"cache"}]}, - "resources":[{"id":"d","type":"directory","mode":"0700"}, - {"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data"]}]}`) - if p := DataProblems(Shelf{"w": declared}); len(p) > 0 { - t.Fatalf("a declared directory is still refused: %v", p) - } -} - -// Consumer data said to live in a provision the module requires is protected only as well as that -// provision's provider protects its consumers' data. -func TestConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) { - idp := mustParse(t, `{"module":"idp","version":"1","requires":["postgres-database"], - "provides":[{"name":"oidc-client","scope":"mesh"}],"grants":{"oidc-client":"${dir:g}"}, - "resources":[{"id":"g","type":"directory","mode":"0700"}], - "data":{"consumers":{"oidc-client":{"class":"irreplaceable","in":"postgres-database"}}}}`) - cachy := mustParse(t, strings.Replace(declaredStore, `"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}`, - `"consumers":{"postgres-database":{"class":"cache"}}`, 1)) - if p := strings.Join(DataProblems(Shelf{"idp": idp, "pg": cachy}), "\n"); !strings.Contains(p, "not protected as irreplaceable") { - t.Fatalf("irreplaceable data kept in a cache passed: %s", p) - } - if p := DataProblems(Shelf{"idp": idp, "pg": mustParse(t, declaredStore)}); len(p) > 0 { - t.Fatalf("refused against a provider that keeps its consumers' data: %v", p) - } -} - // What a binding is sticky by follows the class (ADR 0232, generalised by 0233): a cache's consumers // move freely, a store's do not; an older definition saying nothing still follows its grant. func TestKeepingConsumerDataFollowsTheClass(t *testing.T) { @@ -215,23 +116,3 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { t.Fatalf("an operator's path reads %+v", it) } } - -// Data a consumer keeps with a provider as irreplaceable must be protected there: the check refuses a -// provider that keeps its consumers' data as a cache, or in an item with no protection. -func TestIrreplaceableDataKeptWithAProviderMustBeProtectedThere(t *testing.T) { - photos := mustParse(t, `{"module":"photos","version":"1","requires":["s3-bucket"], - "data":{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}}`) - store := func(backup string) Manifest { - return mustParse(t, `{"module":"objects","version":"1","provides":[{"name":"s3-bucket","scope":"mesh"}], - "grants":{"s3-bucket":"${dir:g}"}, - "data":{"own":[{"id":"data","path":"${dir:data}","class":"valuable","backup":"`+backup+`"}], - "consumers":{"s3-bucket":{"class":"valuable","in":"data"}}}, - "resources":[{"id":"g","type":"directory","mode":"0700"},{"id":"data","type":"directory","mode":"0700"}]}`) - } - if p := DataProblems(Shelf{"photos": photos, "objects": store("copy")}); len(p) > 0 { - t.Fatalf("a provider backing its consumers' data up was refused: %v", p) - } - if p := strings.Join(DataProblems(Shelf{"photos": photos, "objects": store("none")}), "\n"); !strings.Contains(p, "neither backed up") { - t.Fatalf("irreplaceable photos in an unprotected store passed: %s", p) - } -} diff --git a/go/catalogue/decode_for_test_test.go b/go/catalogue/decode_for_test_test.go new file mode 100644 index 0000000..fa3d9c0 --- /dev/null +++ b/go/catalogue/decode_for_test_test.go @@ -0,0 +1,12 @@ +package catalogue + +// decodeForTest builds a manifest for a test as every reader reads one (Decode); the judge's own tests are in +// mesh-delivery's judge (novox/hq ADR 0578 §4). +func decodeForTest(raw []byte) (Manifest, error) { return Decode(raw) } + +// decodeWithForTest is decodeForTest keeping the list of predefined keys it was read with. +func decodeWithForTest(raw []byte, list PredefinedList) (Manifest, error) { + m, err := Decode(raw) + m.SetPredefined(list) + return m, err +} diff --git a/go/catalogue/delivery_seat.go b/go/catalogue/delivery_seat.go index 5cab2ce..f1d2d24 100644 --- a/go/catalogue/delivery_seat.go +++ b/go/catalogue/delivery_seat.go @@ -1,7 +1,5 @@ package catalogue -import "fmt" - // The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery, // which owns a delivery — one commit in one repository, from its pull request's head to every machine — // and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has @@ -107,29 +105,6 @@ func SeatSays(emits []string, event string) bool { return false } -// seatEventProblems refuses a module that lists among its own `emits` an event a seat it claims says (the lead, -// 2026-10-29, issue 1952, the second of a kind: the self-check's `checked` and mesh-hand-acts' `act-recorded`). A -// consumer of `.` hears the seat's subject (SeatSays), so the holder says it there, with stdio.SeatPublish -// on `mesh.seat..event.`; said through stdio.Emit as the module's own, it lands where nobody listens. Caught -// here, when the manifest is read at the build, not when nothing is heard on the machines. -func seatEventProblems(m Manifest) []string { - var problems []string - for _, c := range m.Claims { - seat, known := SeatNamed(c.Name) - if !known || len(seat.Emits) == 0 { - continue - } - for _, e := range m.Emits { - if SeatSays(seat.Emits, e) { - problems = append(problems, fmt.Sprintf("%s lists %q among its own emits, and the seat %s it claims says it: "+ - "say it on the seat with stdio.SeatPublish(\"mesh.seat.%s.event.%s\", ...) and leave it out of emits, or "+ - "no consumer of %s.%s hears it", m.Module, e, seat.Name, seat.Name, e, seat.Name, e)) - } - } - } - return problems -} - // topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and // `>` or `**` the rest. func topicMatches(pattern, event string) bool { diff --git a/go/catalogue/derived_for_consumer_test.go b/go/catalogue/derived_for_consumer_test.go index 8d5e973..7ae5a5b 100644 --- a/go/catalogue/derived_for_consumer_test.go +++ b/go/catalogue/derived_for_consumer_test.go @@ -193,32 +193,6 @@ func TestASettingComposesWithADerivedValue(t *testing.T) { } } -// A fact or an alphabet the mesh does not have is refused where the definition is, not where a -// consumer happens to be resolved — and the refusal says what may be said instead. -func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) { - for _, c := range []struct{ value, says string }{ - {"${consumer:node}", "as"}, - {"${consumer:as:punycode}", "dns"}, - } { - m := store() - m.Serves["s3-bucket"]["bucket"] = c.value - raw, err := json.Marshal(m) - if err != nil { - t.Fatal(err) - } - _, err = ParseManifest(raw) - if err == nil { - t.Fatalf("%s was accepted", c.value) - } - if !strings.Contains(err.Error(), c.value) { - t.Errorf("the refusal of %s does not quote it: %v", c.value, err) - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err) - } - } -} - // `dns` is checked against an identity the mesh actually mints, not an invented string. func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) { as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud")) diff --git a/go/catalogue/dir_into.go b/go/catalogue/dir_into.go index dd3be2f..e9b3f92 100644 --- a/go/catalogue/dir_into.go +++ b/go/catalogue/dir_into.go @@ -106,14 +106,6 @@ func dirsFor(m Manifest, with Rendering) map[string]string { return dirs } -// placedOrAbsolute says a path is usable where the mesh needs one: absolute already, or -// beginning with a placed reference — resolution makes it absolute before anything reads it. -// (unknownDirRefs is what checks the reference names a real directory.) -func placedOrAbsolute(path string) bool { - return strings.HasPrefix(path, "/") || - (strings.HasPrefix(path, "${dir:") && dirRef.MatchString(path)) -} - // dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory. func dirFill(s string, dirs map[string]string, module string) (string, error) { var missing error @@ -260,109 +252,6 @@ func dirInto(resource map[string]any, dirs map[string]string, module string) err return nil } -// unknownDirRefs is every ${dir:…} in the definition that names no directory the definition -// declares — refused where the author is, not at composition on some later day (the same -// near-versus-far reasoning as the host's strict parse). -func (m Manifest) unknownDirRefs() []string { - declared := map[string]bool{} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "directory" { - declared[fmt.Sprint(r["id"])] = true - } - } - referenced := func(s string) []string { - var ids []string - for _, match := range dirRef.FindAllStringSubmatch(s, -1) { - ids = append(ids, match[1]) - } - return ids - } - var problems []string - for _, r := range m.Resources { - place, said := r["place"].(string) - if !said { - continue - } - if fmt.Sprint(r["type"]) != "directory" { - problems = append(problems, fmt.Sprintf( - "%s says place on %v, which is not a directory — only a directory is placed", - m.Module, r["id"])) - continue - } - if path, stated := r["path"].(string); stated && path != "" { - problems = append(problems, fmt.Sprintf( - "%s states both path and place on %v — a stated path IS the placement", - m.Module, r["id"])) - } - if place != placeOwn && place != placeMesh { - problems = append(problems, fmt.Sprintf( - "%s says place %q on %v, and the places are %q — the assignment's own root — and "+ - "%q — where the mesh keeps what it writes for the module", - m.Module, place, r["id"], placeOwn, placeMesh)) - } - } - seen := map[string]bool{} - refuse := func(id string, where any) { - if declared[id] || seen[id] { - return - } - seen[id] = true - problems = append(problems, fmt.Sprintf( - "%s says ${dir:%s} in %v, and declares no directory %q — a reference the mesh "+ - "cannot place would reach the machine as a literal path", - m.Module, id, where, id)) - } - for _, r := range m.Resources { - for _, field := range []string{"path", "content"} { - if s, ok := r[field].(string); ok { - for _, id := range referenced(s) { - refuse(id, r["id"]) - } - } - } - for _, field := range []string{"volumes", "env-file"} { - if list, ok := r[field].([]any); ok { - for _, v := range list { - if s, ok := v.(string); ok { - for _, id := range referenced(s) { - refuse(id, r["id"]) - } - } - } - } - } - if env, ok := r["env"].(map[string]any); ok { - for _, v := range env { - if s, ok := v.(string); ok { - for _, id := range referenced(s) { - refuse(id, r["id"]) - } - } - } - } - } - maps := map[string]map[string]string{ - "receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets, - "own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants, - } - for field, entries := range maps { - for _, value := range entries { - for _, id := range referenced(value) { - refuse(id, field) - } - } - } - for to, locals := range m.SecretsMany { - for _, value := range locals { - for _, id := range referenced(value) { - refuse(id, "secrets."+to) - } - } - } - sort.Strings(problems) - return problems -} - func namesOfDirs(dirs map[string]string) []string { var names []string for id := range dirs { diff --git a/go/catalogue/dir_into_test.go b/go/catalogue/dir_into_test.go index ee7a96e..8c7a3a5 100644 --- a/go/catalogue/dir_into_test.go +++ b/go/catalogue/dir_into_test.go @@ -110,18 +110,6 @@ func TestFillingForOneNodeLeaksIntoNoOther(t *testing.T) { } } -func TestAReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) { - m := placedModule() - m.Resources[2]["volumes"] = []any{"${dir:date}:/data"} // a typo, the likely shape - problems := m.unknownDirRefs() - if len(problems) != 1 || !strings.Contains(problems[0], `${dir:date}`) { - t.Fatalf("a reference naming no directory is a manifest problem; got %v", problems) - } - if got := placedModule().unknownDirRefs(); len(got) != 0 { - t.Fatalf("a correct definition has none; got %v", got) - } -} - func TestAReferenceToNoDirectoryRefusesAtCompositionToo(t *testing.T) { m := placedModule() container := shallowCopy(m.Resources[2]) @@ -188,39 +176,6 @@ func TestTheManifestsMapsArePlaced(t *testing.T) { } } -func TestAMapReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) { - m := Manifest{ - Module: "photos", - Resources: []map[string]any{{"id": "state", "type": "directory", "place": "."}}, - Binds: map[string]string{"route": "${dir:stat}/route.json"}, - } - problems := m.unknownDirRefs() - if len(problems) != 1 || !strings.Contains(problems[0], `${dir:stat}`) { - t.Fatalf("a map naming no directory is a manifest problem; got %v", problems) - } -} - -func TestPlaceIsValidatedAtTheManifest(t *testing.T) { - both := Manifest{Module: "x", Resources: []map[string]any{ - {"id": "d", "type": "directory", "place": ".", "path": "/somewhere"}, - }} - if got := both.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "both path and place") { - t.Fatalf("path beside place refuses; got %v", got) - } - elsewhere := Manifest{Module: "x", Resources: []map[string]any{ - {"id": "f", "type": "file", "place": ".", "path": "/somewhere", "content": ""}, - }} - if got := elsewhere.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "not a directory") { - t.Fatalf("place on a file refuses; got %v", got) - } - wrong := Manifest{Module: "x", Resources: []map[string]any{ - {"id": "d", "type": "directory", "place": "sub/dir"}, - }} - if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) { - t.Fatalf("a place that is neither root refuses; got %v", got) - } -} - func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) { // novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings — // is the mesh's plumbing under /mesh/, and the definition names it by id. @@ -234,9 +189,6 @@ func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) { OwnSecrets: OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}}, Binds: map[string]string{"route": "${dir:state}/route.json"}, } - if got := m.unknownDirRefs(); len(got) != 0 { - t.Fatalf("place %q is a place; got %v", "mesh", got) - } dirs := dirsFor(m, Rendering{}) if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" { t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs) @@ -301,9 +253,6 @@ func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) { {"id": "server", "type": "container", "image": "x@sha256:aa", "volumes": []any{"${dir:runtime-state}:/data"}}, }} - if got := m.unknownDirRefs(); len(got) != 0 { - t.Fatalf("a path beneath a placed directory is well formed; got %v", got) - } dirs := dirsFor(m, Rendering{DataRoot: "/srv"}) if dirs["runtime-state"] != "/srv/mesh/gitea/state" { t.Fatalf("the subdirectory follows the placed one; got %v", dirs) diff --git a/go/catalogue/endpoint_test.go b/go/catalogue/endpoint_test.go index d05f040..4df27f8 100644 --- a/go/catalogue/endpoint_test.go +++ b/go/catalogue/endpoint_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -100,52 +99,6 @@ func TestTwoEndpointsOfDifferentShapesAreConfiguredSeparately(t *testing.T) { } } -// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is -// written rather than resolving to no port and serving nothing. -func TestARouteNamingAnEndpointTheModuleLacksIsRefused(t *testing.T) { - m := aMediaServer() - m.Contributes["route"][RouteEndpoint] = "absent" - got := strings.Join(RouteProblems(m), "\n") - if !strings.Contains(got, "does not declare") { - t.Fatalf("a route naming an absent endpoint was accepted:\n%s", got) - } -} - -// **Two endpoints called the same would make an assignment configure whichever was read last.** The -// point of a name is that it identifies one thing. -func TestTwoEndpointsWithOneNameAreRefused(t *testing.T) { - m := Manifest{Module: "twice", Listens: []Listening{ - {Name: "web", Port: 80, From: FromMesh}, - {Name: "web", Port: 8080, From: FromMesh}, - }} - got := strings.Join(endpointNameProblems(m), "\n") - if !strings.Contains(got, "could mean either") { - t.Fatalf("two endpoints with one name were accepted:\n%s", got) - } -} - -// A name that is not a name is refused where it is written: it ends up in something a person types. -func TestAnEndpointNameIsHeldToItsShape(t *testing.T) { - for _, wrong := range []string{"Web", "web port", "3000", "-web", "web_surface"} { - m := Manifest{Module: "odd", Listens: []Listening{{Name: wrong, Port: 80, From: FromMesh}}} - if got := strings.Join(endpointNameProblems(m), "\n"); !strings.Contains(got, "a name is lowercase") { - t.Fatalf("%q was accepted as an endpoint name:\n%s", wrong, got) - } - } -} - -// **Every endpoint in the catalogue is unnamed today, and must stay valid.** The word ships one -// release before anything uses it. -func TestAnUnnamedEndpointIsStillValid(t *testing.T) { - m := Manifest{Module: "ordinary", Listens: []Listening{{Port: 443, From: FromEverywhere}}} - if got := endpointNameProblems(m); len(got) != 0 { - t.Fatalf("an unnamed endpoint was refused: %v", got) - } - if got := RouteProblems(m); len(got) != 0 { - t.Fatalf("a module with no route was refused: %v", got) - } -} - // **A route that names an endpoint still carries that endpoint's port.** // // Everything downstream reads the port: the provider is told where to reach the consumer, and the diff --git a/go/catalogue/engine_guard_test.go b/go/catalogue/engine_guard_test.go index 2988c9e..1b52013 100644 --- a/go/catalogue/engine_guard_test.go +++ b/go/catalogue/engine_guard_test.go @@ -1,71 +1 @@ package catalogue - -import ( - "go/ast" - "go/parser" - "go/token" - "path/filepath" - "slices" - "strconv" - "testing" - - "git.novox.be/novox/mesh-sdk/go/internal/beside" -) - -// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue -// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone -// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists -// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor -// passes what it refuses by path alone. -func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) { - file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go") - parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0) - if err != nil { - t.Fatalf("the node-engine's guard does not parse: %v", err) - } - lists := map[string][]string{} - consts := map[string]string{} - ast.Inspect(parsed, func(n ast.Node) bool { - spec, ok := n.(*ast.ValueSpec) - if !ok { - return true - } - for i, name := range spec.Names { - if i >= len(spec.Values) { - continue - } - switch v := spec.Values[i].(type) { - case *ast.CompositeLit: - for _, elt := range v.Elts { - if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING { - s, _ := strconv.Unquote(lit.Value) - lists[name.Name] = append(lists[name.Name], s) - } - } - case *ast.BasicLit: - if v.Kind == token.STRING { - consts[name.Name], _ = strconv.Unquote(v.Value) - } - } - } - return true - }) - for name, ours := range map[string][]string{ - "protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees, - } { - theirs := lists[name] - if len(theirs) == 0 { - t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name) - continue - } - a, b := slices.Clone(ours), slices.Clone(theirs) - slices.Sort(a) - slices.Sort(b) - if !slices.Equal(a, b) { - t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b) - } - } - if consts["engineModule"] != engineModule { - t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule) - } -} diff --git a/go/catalogue/environment_into.go b/go/catalogue/environment_into.go index c2faff4..a638f94 100644 --- a/go/catalogue/environment_into.go +++ b/go/catalogue/environment_into.go @@ -114,64 +114,6 @@ var ( ofContributed = regexp.MustCompile(`\$\{(shell|contribution|backup):([^}]*)\}`) ) -// variableName is a POSIX shell variable name, which is also what environment.d accepts. -var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - -// environmentProblems is what is wrong with this module's environment contribution, from the -// manifest alone. -func (m Manifest) environmentProblems() []string { - if m.Environment == nil { - return nil - } - var problems []string - for _, n := range sortedKeys(m.Environment.Variables) { - switch { - case !variableName.MatchString(n): - problems = append(problems, fmt.Sprintf( - "%s sets the variable %q, which is not a name a shell accepts: a letter or an "+ - "underscore, then letters, digits and underscores", m.Module, n)) - continue - case n == "PATH": - // PATH is the one variable every module shares, so no module may set it whole: a second - // setter would replace the first's entries, and the account's own PATH with them. - problems = append(problems, fmt.Sprintf( - "%s sets PATH as a variable; a module adds an entry under environment.path, at the "+ - "start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module)) - continue - } - if why := literalProblem(m.Environment.Variables[n]); why != "" { - problems = append(problems, fmt.Sprintf( - "%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule)) - } - } - seen := map[string]bool{} - for i, p := range m.Environment.Path { - switch { - case p.Entry == "": - problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1)) - case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"): - // A colon is PATH's own separator, so an entry holding one is two entries, and the - // check that it is already present would look for the wrong thing. - problems = append(problems, fmt.Sprintf( - "%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry)) - case seen[p.Entry]: - problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry)) - default: - if why := literalProblem(p.Entry); why != "" { - problems = append(problems, fmt.Sprintf( - "%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule)) - } - } - seen[p.Entry] = true - if p.At != PathAtStart && p.At != PathAtEnd { - problems = append(problems, fmt.Sprintf( - "%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH", - m.Module, p.Entry, p.At, PathAtStart, PathAtEnd)) - } - } - return problems -} - // literalRule is why a value must be literal, said with every refusal of one. const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " + "names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)" @@ -195,46 +137,6 @@ func literalProblem(v string) string { return "" } -// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code -// itself is not judged: it is the shell's syntax, which the controller does not read. -func (m Manifest) shellProblems() []string { - var problems []string - for i, c := range m.Shell { - if !oneOf(contributionTargets(), c.For) { - problems = append(problems, fmt.Sprintf( - "%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+ - "moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "), - strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "))) - } - if !oneOf(knownSlots, c.Slot) { - problems = append(problems, fmt.Sprintf( - "%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot, - strings.Join(knownSlots, ", "))) - } - if strings.TrimSpace(c.Code) == "" { - problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1)) - } - } - return problems -} - -// contributionPlaceholderProblems is every place this module's resources name the environment or -// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before -// a mesh does, and again at composition in the same words. -func (m Manifest) contributionPlaceholderProblems() []string { - var problems []string - for _, r := range m.Resources { - problems = append(problems, placeholderProblems(m, r)...) - problems = append(problems, seatPlaceholderProblems(m, r)...) - problems = append(problems, collectedPlaceholderProblems(m, r)...) - problems = append(problems, consumersPlaceholderProblems(m, r)...) - // And every placeholder no pass fills where it stands: a misspelt namespace, a key its - // namespace cannot take, or a field its pass does not read (novox/hq issue 231). - problems = append(problems, unconsumedPlaceholders(m.Module, r)...) - } - return problems -} - // placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}. // // **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat diff --git a/go/catalogue/environment_into_test.go b/go/catalogue/environment_into_test.go index ca112e9..1df412e 100644 --- a/go/catalogue/environment_into_test.go +++ b/go/catalogue/environment_into_test.go @@ -276,102 +276,6 @@ func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) { } } -// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser, -// which is what the catalogue check and registration run, and again at composition, in the same words. -func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) { - for _, c := range []struct{ content, want string }{ - {"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"}, - {"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"}, - } { - raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err) - } - m := Manifest{Module: "toolchain", Resources: []map[string]any{ - {"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}} - r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}} - if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("composition accepted %s outside its holder: %v", c.content, err) - } - } -} - -// A key nobody renders is refused, not left in the file as a literal. -func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) { - for _, c := range []struct{ content, want string }{ - {"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"}, - {"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell::}"}, - {"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell::}"}, - {"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell::}"}, - } { - raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` + - `"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s was accepted: %v", c.content, err) - } - } - // And outside a file's content, where nothing could be placed. - raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` + - `"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || - !strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") { - t.Errorf("a placeholder in a path was accepted: %v", err) - } -} - -// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else. -func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) { - for _, c := range []struct{ environment, want string }{ - {`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`}, - {`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`}, - {`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`}, - {`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`}, - {`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`}, - {`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`}, - {`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`}, - {`{"variables":{"A":"a\\b"}}`, `which holds a backslash`}, - {`{"variables":{"A":"a\nb"}}`, `which holds a line break`}, - {`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`}, - {`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`}, - {`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`}, - {`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`}, - {`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`}, - {`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`}, - {`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`}, - {`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`}, - } { - _, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`)) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: want %q, got %v", c.environment, c.want, err) - } - } - // What is allowed: a literal, and the machine's own facts. - if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` + - `"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` + - `"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil { - t.Fatalf("a well-formed environment was refused: %v", err) - } -} - -func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) { - for _, c := range []struct{ shell, want string }{ - {`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`}, - {`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`}, - {`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`}, - {`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`}, - } { - _, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`)) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: want %q, got %v", c.shell, c.want, err) - } - } - // The code itself is never judged: a shell's own `${…}` is not the mesh's. - if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` + - `"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil { - t.Fatalf("shell code was judged as if it were the mesh's: %v", err) - } -} - // ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login // shell's contract is `execute`, described and with a schema an agent can call. func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) { @@ -396,29 +300,3 @@ func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) { t.Fatalf("execute takes no timeout: %v", props) } } - -// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's -// name nor under the name a module gave it before. -func TestNoModuleMayDeclareTheLoginShell(t *testing.T) { - for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} { - raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}` - _, err := ParseManifest([]byte(raw)) - if err == nil { - t.Errorf("a module declaring %q was accepted", n) - } - } - got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh", - DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ") - if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) { - t.Fatalf("declaring login-shell was not refused by name: %q", got) - } - // And a shell module claiming the mesh's seat, serving execute, is what the seat is for. - m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` + - `"claims":[{"name":"node-login-shell","scope":"node"}]}`)) - if err != nil { - t.Fatal(err) - } - if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil { - t.Fatalf("a shell module claiming the seat cannot hold it: %v", err) - } -} diff --git a/go/catalogue/environment_items_test.go b/go/catalogue/environment_items_test.go index cd4c93b..ede8942 100644 --- a/go/catalogue/environment_items_test.go +++ b/go/catalogue/environment_items_test.go @@ -129,31 +129,7 @@ func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) { } } -func TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) { - item := func(attrs map[string]string) []string { - return environmentItemProblems("x", Item{Collection: "environment", Attributes: attrs}) - } - for _, ok := range []map[string]string{ - {"kind": "variable", "name": "GTK_THEME"}, - {"kind": "path", "at": "end", "order": "10"}, - } { - if p := item(ok); len(p) != 0 { - t.Errorf("%v is refused: %v", ok, p) - } - } - for _, bad := range []map[string]string{ - nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"}, - {"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"}, - {"kind": "variable", "name": "A", "color": "red"}, - } { - if p := item(bad); len(p) == 0 { - t.Errorf("%v is taken", bad) - } - } - if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") || - attributeFits("environment", "kind", "Variable") { - t.Error("an upper-case name is the environment's name alone") - } +func TestTwoModulesSettingOneVariableAreRefused(t *testing.T) { // A module that sets a variable twice, in two modules, is refused as the old form refused it. a := Manifest{Module: "a", Items: []Item{{Collection: "environment", Content: "1\n", Attributes: map[string]string{"kind": "variable", "name": "X"}}}} diff --git a/go/catalogue/events.go b/go/catalogue/events.go index ce8b9dd..684cae5 100644 --- a/go/catalogue/events.go +++ b/go/catalogue/events.go @@ -1,10 +1,8 @@ package catalogue import ( - "fmt" "regexp" "slices" - "strings" ) // What a module may call an event, and what a consumer may ask for. @@ -36,131 +34,6 @@ const ( TheRest = "**" ) -// EventProblems is what is wrong with a manifest's events. -// -// Refused at registration, because the alternative is a module that installs, starts, connects and -// reacts to nothing — and every log line says it is fine. -func EventProblems(m Manifest) []string { - var problems []string - - for _, e := range m.Emits { - if was, stale := staleEventForm(e, m.Module); stale { - problems = append(problems, fmt.Sprintf( - "%s emits %q, which is the old bus's routing key. An event is named locally now, so "+ - "write %q — the mesh derives the subject (novox/hq design 29 §1)", - m.Module, was, strings.TrimPrefix(was, "module."+m.Module+"."))) - continue - } - if strings.HasPrefix(e, "module.") { - problems = append(problems, fmt.Sprintf( - "%s emits %q: `module.` is reserved, because it is how the old bus spelled a "+ - "routing key and an event named that way derives into a namespace nobody owns", - m.Module, e)) - continue - } - if err := localName(e); err != nil { - problems = append(problems, fmt.Sprintf("%s emits %q: %v", m.Module, e, err)) - continue - } - // **Its own name, never another's.** The bus enforces that a namespace belongs to the module - // it is named for, so an event named for somebody else cannot be published at all. If the - // event is about a role rather than about this module, it belongs on the seat: a name that - // is stable across whoever fills it (04-ISSUES/127). - if first, _, split := strings.Cut(e, "."); split && isAModuleNameOtherThan(first, m.Module) { - problems = append(problems, fmt.Sprintf( - "%s emits %q, which reads as another module's event. A module publishes under its "+ - "own name only. If this is about a role rather than about %s, declare it on that "+ - "seat, where the name survives the holder changing", - m.Module, e, m.Module)) - } - } - - for _, c := range m.Consumes { - if strings.HasPrefix(c, "module.") { - problems = append(problems, fmt.Sprintf( - "%s consumes %q, which is the old bus's pattern. A consumed event names its emitter "+ - "and the event: write %q", m.Module, c, strings.TrimPrefix(c, "module."))) - continue - } - if c == "#" { - problems = append(problems, fmt.Sprintf( - "%s consumes %q, which is the old bus's wildcard for everything. Write %q", - m.Module, c, TheRest)) - continue - } - if err := consumePattern(c); err != nil { - problems = append(problems, fmt.Sprintf("%s consumes %q: %v", m.Module, c, err)) - } - } - return problems -} - -// staleEventForm says an emitted name is this module's own old routing key, and what it was. -func staleEventForm(event, module string) (string, bool) { - return event, module != "" && strings.HasPrefix(event, "module."+module+".") -} - -// isAModuleNameOtherThan says a first token names some module of this mesh that is not this one. -// -// Only the mesh's own seats and the catalogue could answer this properly, and neither is reachable -// from a parser given one manifest. So this catches the case that actually happened — a name that -// is a *provision* the mesh defines, which is where "another module's event" comes from in practice -// — and the whole-catalogue check catches the rest. -func isAModuleNameOtherThan(first, module string) bool { - if first == module || first == "" { - return false - } - if _, isASeat := SeatNamed(first); isASeat { - return true - } - if _, isASeat := SeatDelivering(first); isASeat { - return true - } - return false -} - -// localName checks one event name: dot-separated names, no wildcards, nothing else. -func localName(event string) error { - if event == "" { - return fmt.Errorf("an event needs a name") - } - for _, part := range strings.Split(event, ".") { - if part == OneName || part == TheRest { - return fmt.Errorf("an emitted event names one event, so it carries no wildcard") - } - if !eventName.MatchString(part) { - return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part) - } - } - return nil -} - -// consumePattern checks a consumed pattern: the emitter, then the event, with wildcards. -func consumePattern(pattern string) error { - if pattern == "" { - return fmt.Errorf("a consumed event needs an emitter and an event") - } - parts := strings.Split(pattern, ".") - for i, part := range parts { - switch { - case part == TheRest: - if i != len(parts)-1 { - return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest) - } - case part == OneName: - case !eventName.MatchString(part): - return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part) - } - } - // `**` alone is every event from every module, which the audit logger wants and says plainly. - if len(parts) == 1 && parts[0] != TheRest { - return fmt.Errorf( - "%q names an emitter and no event. Write ., or %q for every event", - pattern, TheRest) - } - return nil -} - // The events a provider says about its consumers (novox/hq ADR 0224): a consumer it has failed // without one success for minutes, and that consumer succeeding again or being withdrawn. The // controller follows them from every module and `status` names a consumer failing until it recovers. diff --git a/go/catalogue/files.go b/go/catalogue/files.go index 81b7839..f7c0a5a 100644 --- a/go/catalogue/files.go +++ b/go/catalogue/files.go @@ -98,16 +98,6 @@ func filesVerbs() []Verb { ) } -// FilesHolderProblems is what a holder of node-files is held to (novox/hq ADR 0394 §2): it runs as the tool -// runner's account, the node's operator account, so it declares no runs-as. -func FilesHolderProblems(m Manifest) []string { - if !m.ClaimsSeat(FilesSeat) || m.RunsAs == "" { - return nil - } - return []string{fmt.Sprintf("%s claims %s and declares runs-as %q: its holder runs as the tool runner's account, "+ - "the node's operator account (novox/hq ADR 0394 §2)", m.Module, FilesSeat, m.RunsAs)} -} - // ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the // paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each // module's manifest places (declaredPaths). A seat's path wins where both name one. diff --git a/go/catalogue/files_test.go b/go/catalogue/files_test.go index 92de928..9ce0a52 100644 --- a/go/catalogue/files_test.go +++ b/go/catalogue/files_test.go @@ -46,20 +46,6 @@ func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) { } } -// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as. -func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) { - p := FilesHolderProblems(Manifest{Module: "files", RunsAs: "mesh-files", Claims: []Claim{{Name: FilesSeat}}}) - if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") { - t.Errorf("a holder declaring runs-as was not refused: %v", p) - } - if p := FilesHolderProblems(Manifest{Module: "files", Claims: []Claim{{Name: FilesSeat}}}); len(p) != 0 { - t.Errorf("a holder declaring none was refused: %v", p) - } - if p := FilesHolderProblems(Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 { - t.Errorf("a non-holder was judged: %v", p) - } -} - // issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node. func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) { files := Manifest{Module: "files", Version: "1", Claims: []Claim{{Name: FilesSeat}}, diff --git a/go/catalogue/filtering.go b/go/catalogue/filtering.go index 46ffd0d..3a14f3f 100644 --- a/go/catalogue/filtering.go +++ b/go/catalogue/filtering.go @@ -1000,32 +1000,6 @@ func Reaches(m Manifest, layers []Layer) (map[int]string, error) { // route that names the endpoint says what it means, and the mesh looks the port up. const RouteEndpoint = "endpoint" -// RouteProblems holds a module's route contributions to naming an endpoint it actually has. -// -// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is -// written rather than resolving to no port and serving nothing — the fault this repository names most -// often, a declaration that reads as though it did something. -func RouteProblems(m Manifest) []string { - var problems []string - check := func(where string, values map[string]any) { - name, ok := values[RouteEndpoint].(string) - if !ok || strings.TrimSpace(name) == "" { - return - } - if _, found := EndpointPort(m, name); !found { - problems = append(problems, fmt.Sprintf( - "%s routes %s to the endpoint %q, which it does not declare", m.Module, where, name)) - } - } - if values, ok := m.Contributes["route"]; ok { - check("a name", values) - } - for local, values := range m.ContributesMany["route"] { - check(local, values) - } - return problems -} - // EndpointsSetting is the settings key that configures a module's endpoints by name, per node // (novox/hq ADR 0138): // diff --git a/go/catalogue/filtering_test.go b/go/catalogue/filtering_test.go index 11cb514..8bf1e00 100644 --- a/go/catalogue/filtering_test.go +++ b/go/catalogue/filtering_test.go @@ -7,22 +7,9 @@ import ( "testing" ) -// A manifest key nothing reads is worse than a key that is refused: five HAL manifests carried -// `scope:`, which read as a restriction and restricted nothing (novox/hq 04-ISSUES/003). The host's -// declaration parser has always refused unknown keys; manifests never did. -func TestAManifestKeyNothingReadsIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"web","scope":"mesh"}`)) - if err == nil { - t.Fatal("a manifest with a key nothing reads was accepted, which is how scope: survived") - } - if !strings.Contains(err.Error(), "scope") { - t.Fatalf("refused without naming the key, which leaves the author guessing: %v", err) - } -} - // The same discipline must not refuse a manifest that is merely ordinary. func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) { - m, err := ParseManifest([]byte( + m, err := decodeForTest([]byte( `{"module":"web","listens":[{"port":443,"from":"anywhere","why":"the site"}]}`)) if err != nil { t.Fatalf("an ordinary manifest was refused: %v", err) @@ -32,24 +19,6 @@ func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) { } } -// A rule with no source is open, and must say so rather than appear to restrict something. -func TestAPortWithNoSourceIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443}]}`)) - if err == nil { - t.Fatal("a port that never said who may reach it was accepted; it reads as a restriction") - } - if !strings.Contains(err.Error(), "from where") { - t.Fatalf("refused without saying what is missing: %v", err) - } -} - -func TestASourceNobodyDefinedIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443,"from":"lan"}]}`)) - if err == nil { - t.Fatal("a source the mesh cannot render was accepted, and would be silently dropped") - } -} - // The derivation is over the whole node, not one module. func TestTheRuleSetIsEveryAssignedModulesPorts(t *testing.T) { r := Resolution{Modules: []Manifest{ @@ -357,13 +326,6 @@ func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) { } } -func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"firewall","filtering":{"into":""}}`)) - if err == nil { - t.Fatal("a module asked for the rule set and named no path; it would receive nothing") - } -} - // nftables matches the two address families separately, and one set holding both is a syntax // error — so the file fails to load, the service reports a fault, and the machine filters nothing. func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) { diff --git a/go/catalogue/go_bundle_test.go b/go/catalogue/go_bundle_test.go index eb6cd2b..4cf1196 100644 --- a/go/catalogue/go_bundle_test.go +++ b/go/catalogue/go_bundle_test.go @@ -1,37 +1,6 @@ package catalogue -import "testing" - // A bundle is the module's own directory compiled whole, and naming a source would be describing its // own build. That holds for an interpreted language and cannot hold for a compiled one: a repository // written in Go carries several commands — the host and its bootstrap live in one — and "the module's // own directory" is then not a package at all (novox/hq 04-ISSUES/142). - -func TestAGoBundleMayNameItsCommand(t *testing.T) { - b := &Build{Artifacts: []Artifact{{ - Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch", - From: "cmd/mesh-host", - }}} - if p := b.problems("mesh-host"); len(p) != 0 { - t.Fatalf("a go bundle naming its command was refused: %v", p) - } -} - -func TestAnInterpretedBundleStillMayNotNameASource(t *testing.T) { - b := &Build{Artifacts: []Artifact{{ - Name: "tools", Kind: ArtifactBundle, Language: "typescript", From: "src", - }}} - p := b.problems("something") - if len(p) == 0 { - t.Fatal("an interpreted bundle naming what it is built from was accepted") - } -} - -func TestACompiledBundleStillMustSayItsSystem(t *testing.T) { - b := &Build{Artifacts: []Artifact{{ - Name: "host", Kind: ArtifactBundle, Language: "go", From: "cmd/mesh-host", - }}} - if len(b.problems("mesh-host")) == 0 { - t.Fatal("a compiled bundle with no system was accepted") - } -} diff --git a/go/catalogue/graphical_session_test.go b/go/catalogue/graphical_session_test.go index f412e24..6524634 100644 --- a/go/catalogue/graphical_session_test.go +++ b/go/catalogue/graphical_session_test.go @@ -61,14 +61,6 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) { } } -// §2: a module may claim one of them, and may not declare it as its own. -func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) { - raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") { - t.Fatalf("a module declared node-display-server as its own: %v", err) - } -} - func displayServer(name, display string, claims ...string) Manifest { m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}} for _, c := range claims { @@ -126,29 +118,6 @@ func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *test } } -func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) { - for _, c := range []struct{ provides, want string }{ - {`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`}, - {`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`}, - } { - _, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`)) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: want %q, got %v", c.provides, c.want, err) - } - } - m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`)) - if err != nil { - t.Fatal(err) - } - if !m.Provides[0].MachineReach() { - t.Fatal("the reach was not read") - } - back, _ := json.Marshal(m.Provides[0]) - if string(back) != `{"name":"x11-display","reach":"machine"}` { - t.Errorf("written back as %s", back) - } -} - func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) { cat := shelf(windowManager(), displayServer("xorg", "x11-display"), Manifest{Module: "fake-x", Provides: Offers("x11-display")}) @@ -157,57 +126,3 @@ func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) { t.Fatalf("a provision with and without the machine's reach gave %v", err) } } - -// §4: xinitrc and xresources slots, placed by the display server's holder alone. -func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) { - xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}}, - Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}}, - Resources: []map[string]any{ - {"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc", - "content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"}, - {"id": "xresources", "type": "file", "path": "/home/op/.Xresources", - "content": "${shell:xresources:normal}"}, - }} - i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}} - theme := Manifest{Module: "theme", Shell: []ShellCode{ - {For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"}, - {For: "zsh", Slot: "normal", Code: "not for the session"}, - }} - r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}} - out, err := r.Declaration(Rendering{}) - if err != nil { - t.Fatal(err) - } - by := map[string]any{} - for _, res := range out { - by[res["id"].(string)] = res["content"] - } - if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" { - t.Errorf("the .xinitrc is %q", got) - } - if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" { - t.Errorf("the .Xresources is %q", got) - } - - // The contributions parse; the placeholders parse only in the holder. - if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` + - `{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil { - t.Fatalf("a session contribution was refused: %v", err) - } - for _, c := range []struct{ claims, content, want string }{ - {``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"}, - {`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"}, - {`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"}, - {`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"}, - } { - raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + - c.content + `"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err) - } - } - if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` + - `"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil { - t.Errorf("the display server's holder could not place the session's slots: %v", err) - } -} diff --git a/go/catalogue/groups_test.go b/go/catalogue/groups_test.go index b12276d..e3a5484 100644 --- a/go/catalogue/groups_test.go +++ b/go/catalogue/groups_test.go @@ -58,26 +58,6 @@ func TestAGroupThatIsNotAWholeNumberAboveZeroIsRefused(t *testing.T) { } } -// A manifest in the config form registers with the group a config value fills, and the value is placed (a container's -// groups count as a place for it); a placeholder in any other shape is refused where the manifest is read. -func TestAManifestWhoseContainerTakesItsGroupFromConfigRegisters(t *testing.T) { - m := map[string]any{ - "module": "n8n", "version": "1", - "config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true, - "description": "the group that owns the media library on this machine"}}, - "resources": []any{aContainerWithGroups("${config:media-gid}")}, - } - if _, err := parseRaw(t, m); err != nil { - t.Fatalf("refused: %v", err) - } - for _, shape := range []string{"x${config:media-gid}", "${config:media-gid}:1", "${config:other}"} { - m["resources"] = []any{aContainerWithGroups(shape)} - if _, err := parseRaw(t, m); err == nil { - t.Errorf("groups %q registered", shape) - } - } -} - // 1636/AC3 composed for a machine, the way a push writes it: n8n's group is the number the machine's config value // says, written as a number, and the module is left out by name when the machine says none. func TestAComposedMachineGetsTheGroupTheConfigValueNames(t *testing.T) { diff --git a/go/catalogue/handed.go b/go/catalogue/handed.go index 7d692b4..54b1b46 100644 --- a/go/catalogue/handed.go +++ b/go/catalogue/handed.go @@ -305,43 +305,6 @@ func takesOf(m Manifest, id string) string { return takes } -// movedProblems is what is wrong with a module's mark: a moved module must name a template (as written) or carry -// what its build kept (as held), and Handed is the build's word, never a repository's. -func movedProblems(m Manifest) []string { - switch { - case m.Handed != nil && !m.Moved: - return []string{fmt.Sprintf("%s carries what a moved module's build keeps (handed) and is not marked moved", - m.Module)} - case m.Handed != nil && m.Build != nil: - return []string{fmt.Sprintf("%s says handed in its repository: that is what its build keeps, never "+ - "written by hand (novox/hq ADR 0349 §2)", m.Module)} - case m.Moved && m.Handed == nil && !m.Templated(): - return []string{fmt.Sprintf("%s is marked moved and names no template: a moved module's files are filled "+ - "from template files of its own, `\"template\": \"%s/\"` (novox/hq ADR 0349 §2)", m.Module, - m.TemplatesDir())} - } - if !m.Moved { - return nil - } - // **A moved module's secrets are the vault's, handed to its machine's client** (design 57 V4): a secret the - // mesh makes, by name. A family's members and a value issued outside the mesh are given by a person and may be - // absent, which the client cannot tell from one the vault does not hold yet; they move with a package of their - // own. - var problems []string - if fams := m.OwnSecrets.Families(); len(fams) > 0 { - problems = append(problems, fmt.Sprintf("%s is marked moved and keeps a secret family (%s): a moved module's "+ - "secrets are handed by the vault by name, and a family's members are not yet (design 57 V4)", m.Module, - strings.Join(fams, ", "))) - } - for _, name := range sortedKeys(m.OwnSecrets.Plain()) { - if m.OwnSecrets[name].IssuedBy == IssuedOutside { - problems = append(problems, fmt.Sprintf("%s is marked moved and its secret %s is issued outside the mesh: "+ - "a value that may not be given is not yet handed by the vault (design 57 V4)", m.Module, name)) - } - } - return problems -} - // handedBy is what a moved module's build keeps, from its templates archive as built, or nil for a module not // moved. func handedBy(m Manifest, archive Built) *Handed { diff --git a/go/catalogue/handed_consumers_test.go b/go/catalogue/handed_consumers_test.go index abef0b9..ae8ce1e 100644 --- a/go/catalogue/handed_consumers_test.go +++ b/go/catalogue/handed_consumers_test.go @@ -48,7 +48,7 @@ func TestWhatAConsumersPlaceholderMayNotBeIsRefused(t *testing.T) { "a file the program keeps": {strings.Replace(handedProvider, `"mode":"0600","template"`, `"mode":"0600","create-once":true,"template"`, 1), consumersText, "owned"}, } { - m, err := ParseManifest([]byte(c.raw)) + m, err := decodeForTest([]byte(c.raw)) if err != nil { t.Fatalf("%s: %v", why, err) } diff --git a/go/catalogue/handed_providers_test.go b/go/catalogue/handed_providers_test.go index daee6d3..50592b6 100644 --- a/go/catalogue/handed_providers_test.go +++ b/go/catalogue/handed_providers_test.go @@ -116,68 +116,6 @@ func TestAHandedModulesIssuedCredentialIsItsProvisionsOneEntry(t *testing.T) { } } -// Each entry names the credential form the provider's offer declares (issue 768), from the provider's manifest, so -// the vault makes a first value in that form for a provider it holds no row of: one of its own secrets shared by -// every consumer, a login per consumer when the offer says no credential, issued by its software, or given by a -// person (the offer's own secret issued outside). -func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) { - storeOffering := func(credential string) Manifest { - raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + - credential + `}],"serves":{"postgres-database":{"port":5432}}` - if strings.Contains(credential, `"own"`) { - raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"},` + - `"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}` - } - return mustParse(t, raw+`}`) - } - for credential, want := range map[string]string{ - // No default (the lead, 2026-10-29): an offer that says nothing has no form, and the catalogue refuses it. - ``: "", - `,"credential":{"form":"pair"}`: CredentialFormPair, - `,"credential":{"form":"none"}`: CredentialFormNone, - `,"credential":{"own":"admin"}`: CredentialFormShared, - `,"credential":{"own":"token"}`: CredentialFormGiven, - `,"credential":{"issued-by":"outside"}`: CredentialFormGiven, - `,"credential":{"issued-by":"software","tool":"store_issued_token"}`: CredentialFormIssued, - } { - node := workstation() - node.At = "workstation.internal" - consumer := notifierConsumer(t, true, false) - r, err := Resolve(shelf(consumer, storeOffering(credential)), []string{consumer.Module}, node, twoStores("anchor")) - if err != nil { - t.Fatal(err) - } - r.Account = "op" - c, _ := composeWith(t, r, handingTo("store:5000")) - got := c.Plans["notify"].Provisions - if len(got) != 1 || got[0].CredentialForm != want { - t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want) - } - // Every form a plan names is in the one list each reader checks (issue 774); an undeclared one is not. - if IsCredentialForm(want) != (want != "") { - t.Errorf("the form %q of an offer with %q: in CredentialForms %v", want, credential, IsCredentialForm(want)) - } - } - if forms := strings.Join(CredentialForms(), ","); forms != "shared,pair,issued,given,none" || IsCredentialForm("maybe") { - t.Errorf("the credential forms are %s, and maybe is one: %v", forms, IsCredentialForm("maybe")) - } - if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" { - t.Errorf("a module offering nothing has the credential form %q", got) - } - // The catalogue refuses an offer saying no form, and a form beside an own secret or not pair or none. - for credential, refused := range map[string]string{``: "says no credential form", `,"credential":{}`: "says no credential form", - `,"credential":{"form":"pair","own":"admin"}`: "beside an own secret", `,"credential":{"form":"maybe"}`: `not "pair" or "none"`, - `,"credential":{"form":"none"}`: ""} { - raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + credential + `}]}` - var m Manifest - _ = json.Unmarshal([]byte(raw), &m) - problems := strings.Join(CredentialFormProblems(m), "; ") - if (refused == "") != (problems == "") || !strings.Contains(problems, refused) { - t.Errorf("%q: %q, want %q", credential, problems, refused) - } - } -} - // A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's // helper learns a database's name from the bus; a consumer contributing nothing has the entry it had. func TestAHandedModulesPlanEntryCarriesWhatItContributesToItsProvider(t *testing.T) { @@ -258,7 +196,7 @@ func TestAnOfferAnsweredByTheHelperIsReadAndHandsNoFile(t *testing.T) { if err != nil || !strings.Contains(string(raw), `"answered-by":"helper"`) { t.Fatalf("written back as %s: %v", raw, err) } - if _, err := ParseManifest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil { + if _, err := decodeForTest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil { t.Fatal("an unknown answerer was accepted") } c1, before := composeWith(t, notifierOn(t, held(t, without, builtNotifier())), handingTo("store:5000")) diff --git a/go/catalogue/handed_pulled_in_test.go b/go/catalogue/handed_pulled_in_test.go index cfec741..188cbba 100644 --- a/go/catalogue/handed_pulled_in_test.go +++ b/go/catalogue/handed_pulled_in_test.go @@ -22,7 +22,7 @@ func providing(raw string) string { // onWorkstation resolves m on the workstation either assigned beside desk, or pulled in by desk alone. func onWorkstation(t *testing.T, m Manifest, assigned bool) Resolution { t.Helper() - desk, err := ParseManifest([]byte(pulledInBy)) + desk, err := decodeForTest([]byte(pulledInBy)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/handed_secrets_test.go b/go/catalogue/handed_secrets_test.go index a8a8760..794a312 100644 --- a/go/catalogue/handed_secrets_test.go +++ b/go/catalogue/handed_secrets_test.go @@ -123,37 +123,6 @@ func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) { } } -func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) { - // A template naming a credential the module requires: a binding's, which moves with its provider. - requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1) - r, err := Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")), - Manifest{Module: "db", Version: "1", Provides: []Offer{{Name: "database", Scope: ScopeNode}}}), - []string{"bot"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - r.Account = "op" - if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") { - t.Errorf("a template naming a required credential: %v", err) - } - // A file with its text inline naming a secret, in a handed module. - inline := strings.Replace(botMoved(), `{"id":"daemon"`, - `{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1) - r = notifierOn(t, held(t, inline, builtBot(botText))) - if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") { - t.Errorf("an inline file naming a secret in a handed module: %v", err) - } - // A family and a secret issued outside the mesh are not handed by name yet: the mark is refused. - for why, raw := range map[string]string{ - "a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1), - "issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1), - } { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") { - t.Errorf("a moved module with %s: %v", why, err) - } - } -} - // A credential of what a handed module requires that the vault keeps for it alone (issue 757): the forge's read // token for one agent, issued by the forge's software per holder. Where the module is handed, its file is declared // placed and without a value, and the plan names it with its provision and provider; anywhere else, and for a diff --git a/go/catalogue/handed_test.go b/go/catalogue/handed_test.go index dbe3bec..8ed924e 100644 --- a/go/catalogue/handed_test.go +++ b/go/catalogue/handed_test.go @@ -141,24 +141,6 @@ func TestAMovedModulesTemplatedFileIsHandedToTheProvisioner(t *testing.T) { } } -func TestAMovedModulesMarkIsJudged(t *testing.T) { - for name, raw := range map[string]string{ - "marked moved and naming no template": `{"module":"bare","version":"1","moved":true, - "resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`, - "handed written in a repository": `{"module":"bare","version":"1","moved":true, - "handed":{"source":"s","digest":"d","templates":{}}, - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, - "resources":[{"id":"conf","type":"file","path":"/etc/bare","template":"files/bare"}]}`, - "handed and not moved": `{"module":"bare","version":"1", - "handed":{"source":"s","digest":"d","templates":{}}, - "resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`, - } { - if _, err := ParseManifest([]byte(raw)); err == nil { - t.Errorf("%s: accepted", name) - } - } -} - // What `module check` and the merge gate judge: a moved module read from its tree, its text inline. It is still // marked moved, and composes as before, since nothing of its build's archive is there to hand. func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) { @@ -169,7 +151,7 @@ func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "files", "rc"), []byte(notifierText), 0o644); err != nil { t.Fatal(err) } - m, err := ParseManifest([]byte(notifierMoved())) + m, err := decodeForTest([]byte(notifierMoved())) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/health.go b/go/catalogue/health.go index 1ff2d0d..058c9a8 100644 --- a/go/catalogue/health.go +++ b/go/catalogue/health.go @@ -174,207 +174,6 @@ func ReadHealth(r map[string]any) (Health, bool, []string) { return h, true, problems } -// healthProblems is everything wrong with a manifest's `health` fields, in the manifest's words (to-be -// 48 §8): a field on something that does not stay up, a kind its resource cannot have, an endpoint the -// module does not declare, a port or an address, a timing outside its bounds, a tool the module does not -// serve, and a tool check with no check of another kind beside it on the module. -func healthProblems(m Manifest) []string { - var problems []string - endpoints := map[string]Listening{} - for _, l := range m.Listens { - if n := strings.TrimSpace(l.Name); n != "" { - endpoints[n] = l - } - } - tools := map[string]bool{} - for _, t := range m.Tools { - tools[t] = true - } - wants := map[string]bool{} - for _, w := range m.Wants() { - wants[w] = true - } - var toolChecks []string - otherKinds := 0 - resources := m.Resources - // A module's own runtime is judged as the process the controller writes for it (novox/hq ADR 0366 §1), - // and only a module that runs as its own account has one. - own, hasOwn := OwnRuntimeHealth(m) - if hasOwn { - if m.RunsAs == "" { - problems = append(problems, fmt.Sprintf("%s declares runtime-health and runs as no account of its own "+ - "(runs-as): only a module served by a runtime of its own has that runtime's health to declare "+ - "(novox/hq ADR 0366 §1)", m.Module)) - hasOwn = false - } else { - resources = append(append([]map[string]any(nil), m.Resources...), own) - } - } - for i, r := range resources { - h, has, read := ReadHealth(r) - if !has { - continue - } - ownRuntime := hasOwn && i == len(resources)-1 - id, typ := fmt.Sprint(r["id"]), fmt.Sprint(r["type"]) - where := m.Module + ": " + id - if ownRuntime { - where = m.Module + ": its runtime-health" - } - for _, p := range read { - problems = append(problems, m.Module+": "+p) - } - if !LongRunning(r) { - problems = append(problems, fmt.Sprintf("%s declares health and does not stay up: a step, anything on a "+ - "schedule and a service not stated running are judged by their step and their schedule (ADR 0240 rule 1)", where)) - continue - } - switch h.Kind { - case HealthRuntime, HealthExec: - if typ != "container" { - problems = append(problems, fmt.Sprintf("%s is a %s and its health is %q, which only a container has: "+ - "the runtime runs it inside the container", where, typ, h.Kind)) - } - case HealthUnit: - if typ == "container" { - problems = append(problems, fmt.Sprintf("%s is a container and its health is %q, which is a service's "+ - "or a process's own readiness", where, h.Kind)) - } - case HealthHTTP, HealthTCP, HealthTool: - case "": - problems = append(problems, fmt.Sprintf("%s declares health with no kind: %s", where, healthKindsWords())) - default: - problems = append(problems, fmt.Sprintf("%s declares health of kind %q: %s", where, h.Kind, healthKindsWords())) - } - switch h.Kind { - case HealthHTTP, HealthTCP: - l, declared := endpoints[h.Endpoint] - switch { - case h.Endpoint == "": - problems = append(problems, fmt.Sprintf("%s's %s check names no endpoint: it names one the module "+ - "declares under listens, by its name", where, h.Kind)) - case !declared: - problems = append(problems, fmt.Sprintf("%s's %s check names the endpoint %q, which %s does not declare "+ - "under listens (%s)", where, h.Kind, h.Endpoint, m.Module, namedEndpointsWords(endpoints))) - case l.At() != "tcp": - problems = append(problems, fmt.Sprintf("%s's %s check names %q, which is %s: a check connects over tcp", - where, h.Kind, h.Endpoint, l.At())) - } - default: - if h.Endpoint != "" { - problems = append(problems, fmt.Sprintf("%s's %s check names an endpoint, which only an http or tcp check "+ - "looks at", where, h.Kind)) - } - } - if h.Kind != HealthHTTP && (h.Path != "" || h.Status != 0 || h.Body != "" || h.Scheme != "") { - problems = append(problems, fmt.Sprintf("%s's %s check says a path, a status, a body or a scheme, which "+ - "only an http check has", where, h.Kind)) - } - if h.Kind == HealthHTTP { - if h.Path != "" && !strings.HasPrefix(h.Path, "/") { - problems = append(problems, fmt.Sprintf("%s's http check asks %q; a path starts with /", where, h.Path)) - } - if h.Status != 0 && (h.Status < 100 || h.Status > 599) { - problems = append(problems, fmt.Sprintf("%s's http check expects status %d, which is not one", where, h.Status)) - } - if h.Scheme != "" && h.Scheme != "http" && h.Scheme != "https" { - problems = append(problems, fmt.Sprintf("%s's http check is over %q; it is http or https", where, h.Scheme)) - } - } - if (h.Command != "") != (h.Kind == HealthExec) { - if h.Kind == HealthExec { - problems = append(problems, fmt.Sprintf("%s's exec check says no command", where)) - } else { - problems = append(problems, fmt.Sprintf("%s's %s check says a command, which only an exec check runs", - where, h.Kind)) - } - } - if h.Kind == HealthTool { - switch { - case h.Tool == "": - problems = append(problems, fmt.Sprintf("%s's tool check names no tool", where)) - case !tools[h.Tool]: - problems = append(problems, fmt.Sprintf("%s's tool check asks %q, which %s does not serve (its tools: %s)", - where, h.Tool, m.Module, orNoneWords(m.Tools))) - } - // Its own runtime being alive, judged by the node-engine, is the check the module does not run - // itself that this tool check stands beside (novox/hq ADR 0366 §2). - if !ownRuntime { - toolChecks = append(toolChecks, id) - } - } else { - if h.Tool != "" { - problems = append(problems, fmt.Sprintf("%s's %s check names a tool, which only a tool check asks", where, h.Kind)) - } - if h.Kind != "" { - otherKinds++ - } - } - if h.Needs != "" && !wants[h.Needs] { - problems = append(problems, fmt.Sprintf("%s's check needs %q, which %s does not require: a check names "+ - "the provision it exercises, among those the module requires", where, h.Needs, m.Module)) - } - problems = append(problems, healthTimingProblems(where, h)...) - } - if len(toolChecks) > 0 && otherKinds == 0 { - problems = append(problems, fmt.Sprintf("%s judges itself only by its own tool (%s): a tool check is for "+ - "function no endpoint shows, and only beside a check of another kind the module does not run itself "+ - "(ADR 0227 rule 8, ADR 0240 rule 2)", m.Module, strings.Join(toolChecks, ", "))) - } - return problems -} - -// healthTimingProblems holds a check's timing to its bounds. -func healthTimingProblems(where string, h Health) []string { - var problems []string - if h.Interval < HealthIntervalFloor { - problems = append(problems, fmt.Sprintf("%s looks every %s; a check looks no more often than every %s — "+ - "the mesh is a guest on the machine (ADR 0240 rule 2)", where, h.Interval, HealthIntervalFloor)) - } - if h.Timeout <= 0 || h.Timeout >= h.Interval { - problems = append(problems, fmt.Sprintf("%s gives a look %s, which must be more than nothing and under its "+ - "interval of %s", where, h.Timeout, h.Interval)) - } - if h.Looks < HealthLooksFloor { - problems = append(problems, fmt.Sprintf("%s is unhealthy after %d failing look(s); it is at least %d — one "+ - "look can be wrong (issue 277)", where, h.Looks, HealthLooksFloor)) - } - if h.Grace < 0 { - problems = append(problems, fmt.Sprintf("%s has a grace of %s", where, h.Grace)) - } - if h.Looks >= HealthLooksFloor && h.Interval >= HealthIntervalFloor { - if took := h.Grace + time.Duration(h.Looks)*h.Interval; took > HealthWithin { - problems = append(problems, fmt.Sprintf("%s is said unhealthy at the earliest %s after it starts (a grace "+ - "of %s and %d looks every %s); it is at most %s, so a resource broken from its start is said within "+ - "the gate's bound", where, took, h.Grace, h.Looks, h.Interval, HealthWithin)) - } - } - return problems -} - -func healthKindsWords() string { - return "a check is runtime (the image's own, adopted by name), http, tcp, exec, unit or tool" -} - -func namedEndpointsWords(endpoints map[string]Listening) string { - if len(endpoints) == 0 { - return "it names none" - } - names := make([]string, 0, len(endpoints)) - for n := range endpoints { - names = append(names, n) - } - sort.Strings(names) - return "it names " + strings.Join(names, ", ") -} - -func orNoneWords(names []string) string { - if len(names) == 0 { - return "none" - } - return strings.Join(names, ", ") -} - // Undeclared is every long-running resource of the manifest without `health`, by id (ADR 0240 rule 8): // what the catalogue's count counts. func Undeclared(m Manifest) []string { diff --git a/go/catalogue/health_test.go b/go/catalogue/health_test.go index ef3ae04..3b50ec9 100644 --- a/go/catalogue/health_test.go +++ b/go/catalogue/health_test.go @@ -53,78 +53,24 @@ func TestAWellFormedHealthIsAccepted(t *testing.T) { {"kind": "runtime"}, {"kind": "exec", "command": "pg_isready -q", "grace": "4m", "looks": 2, "interval": "30s"}, } { - if _, err := ParseManifest(healthManifest(t, "server", h)); err != nil { + if _, err := decodeForTest(healthManifest(t, "server", h)); err != nil { t.Errorf("%v was refused: %v", h, err) } } - if _, err := ParseManifest(healthManifest(t, "daemon", map[string]any{"kind": "unit"})); err != nil { + if _, err := decodeForTest(healthManifest(t, "daemon", map[string]any{"kind": "unit"})); err != nil { t.Errorf("a service's own readiness was refused: %v", err) } // A tool beside a check of another kind on the module. raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"}, map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running", "health": map[string]any{"kind": "tool", "tool": "app_status"}}) - if _, err := ParseManifest(raw); err != nil { + if _, err := decodeForTest(raw); err != nil { t.Errorf("a tool check beside an http check was refused: %v", err) } } -func TestEveryOutOfBoundsHealthIsRefusedByName(t *testing.T) { - cases := []struct { - name string - on string - health map[string]any - says string - }{ - {"no kind", "server", map[string]any{"endpoint": "web"}, "with no kind"}, - {"an unknown kind", "server", map[string]any{"kind": "ping"}, `of kind "ping"`}, - {"a port", "server", map[string]any{"kind": "tcp", "port": 8080}, "names a port"}, - {"an address", "server", map[string]any{"kind": "http", "endpoint": "web", "address": "127.0.0.1"}, "names a address"}, - {"a url", "server", map[string]any{"kind": "http", "url": "http://localhost:8080/"}, "names a url"}, - {"an unknown key", "server", map[string]any{"kind": "tcp", "endpoint": "web", "retries": 3}, `"retries"`}, - {"no endpoint", "server", map[string]any{"kind": "http"}, "names no endpoint"}, - {"an undeclared endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "admin"}, "does not declare"}, - {"a udp endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "beacon"}, "a check connects over tcp"}, - {"an interval under the floor", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "5s", "timeout": "1s"}, "no more often than every 10s"}, - {"a timeout of the interval", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "10s"}, "under its interval"}, - {"one failing look", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": 1}, "at least 2"}, - {"a grace and looks past five minutes", "server", map[string]any{"kind": "tcp", "endpoint": "web", "grace": "4m", "looks": 3, "interval": "30s"}, "at most 5m0s"}, - {"a duration that is not one", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "often"}, "is a duration"}, - {"looks that are not a number", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": "three"}, "whole number"}, - {"a path without a slash", "server", map[string]any{"kind": "http", "endpoint": "web", "path": "health"}, "starts with /"}, - {"a status that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "status": 700}, "is not one"}, - {"a scheme that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "scheme": "ftp"}, "http or https"}, - {"a status on a tcp check", "server", map[string]any{"kind": "tcp", "endpoint": "web", "status": 200}, "only an http check has"}, - {"an exec with no command", "server", map[string]any{"kind": "exec"}, "says no command"}, - {"a command on an http check", "server", map[string]any{"kind": "http", "endpoint": "web", "command": "true"}, "only an exec check runs"}, - {"a runtime check on a service", "daemon", map[string]any{"kind": "runtime"}, "only a container has"}, - {"a unit check on a container", "server", map[string]any{"kind": "unit"}, "a service's or a process's"}, - {"a tool the module does not serve", "server", map[string]any{"kind": "tool", "tool": "app_admin"}, "does not serve"}, - {"a tool check alone", "server", map[string]any{"kind": "tool", "tool": "app_status"}, "judges itself only by its own tool"}, - {"needs not required", "server", map[string]any{"kind": "tcp", "endpoint": "web", "needs": "redis"}, "does not require"}, - {"health on a step", "seed", map[string]any{"kind": "runtime"}, "does not stay up"}, - {"health on a schedule", "sweep", map[string]any{"kind": "runtime"}, "does not stay up"}, - {"health that is not an object", "server", nil, "is an object"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - raw := healthManifest(t, c.on, c.health) - if c.health == nil { - raw = []byte(strings.Replace(string(raw), `"name":"app-server"`, `"name":"app-server","health":"tcp"`, 1)) - } - _, err := ParseManifest(raw) - if err == nil { - t.Fatalf("%s was accepted", c.name) - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("refused, but not for saying %q: %v", c.says, err) - } - }) - } -} - func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) { - m, err := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz", + m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz", "needs": "postgres-database"})) if err != nil { t.Fatal(err) @@ -163,7 +109,7 @@ func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) { } func TestTheUndeclaredAreTheLongRunningWithoutHealth(t *testing.T) { - m, err := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "runtime"})) + m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "runtime"})) if err != nil { t.Fatal(err) } @@ -176,7 +122,7 @@ func TestATooledHealthIsGrantedToTheEngine(t *testing.T) { raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"}, map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running", "health": map[string]any{"kind": "tool", "tool": "app_status"}}) - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { t.Fatal(err) } @@ -194,7 +140,7 @@ func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) { {"kind": "runtime"}, {"kind": "exec", "command": "pg_isready -q"}, } { - m, err := ParseManifest(healthManifest(t, "server", h, + m, err := decodeForTest(healthManifest(t, "server", h, map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running", "health": map[string]any{"kind": "tool", "tool": "app_status"}})) if err != nil { diff --git a/go/catalogue/identity.go b/go/catalogue/identity.go index 0400735..376f3df 100644 --- a/go/catalogue/identity.go +++ b/go/catalogue/identity.go @@ -174,47 +174,3 @@ func (r Resolution) Overflowing() []Overflow { // passes on every machine that mesh has. `module check --longest-machine-name` says another mesh's; // a mesh that names a longer machine raises this in the same change (ADR 0225). const DefaultLongestMachine = 6 - -// IdentityProblems is every module whose identity would overflow a provision it wants, on a machine -// whose name is `longestMachine` characters — judged before merge, over the catalogue alone, so the -// pull request that introduces an overflow is the one refused (novox/hq ADR 0225, issue 263). A -// provision no module in the shelf offers is not judged: its bound is not known here. -func IdentityProblems(shelf Shelf, longestMachine int) []string { - offeredBy := map[string][]string{} - for _, name := range shelfOrder(shelf) { - for _, o := range shelf[name].Offers() { - offeredBy[o] = append(offeredBy[o], name) - } - } - machine := strings.Repeat("n", longestMachine) - var problems []string - for _, name := range shelfOrder(shelf) { - m := shelf[name] - source := IdentitySource(m.Slug, m.Module) - for _, want := range m.Wants() { - // The tightest bound among the modules offering it: whichever one answers on a given - // machine, the identity has to fit it. - tightest, by := IdentityBound{}, "" - for _, provider := range offeredBy[want] { - if provider == name { - continue // a module answering its own requirement is not its own consumer - } - b := shelf[provider].IdentityBoundOf(want) - if b.Bounded() && (!tightest.Bounded() || b.Max < tightest.Max) { - tightest, by = b, provider - } - } - if CheckIdentityWithin(machine, source, tightest) == nil { - continue - } - got := ConsumerIdentity(machine, source) - problems = append(problems, fmt.Sprintf( - "%s wants %s, and %s keeps its consumers' identities in %s of at most %d characters: "+ - "on a machine with a %d-character name it is identified as %q, %d — give %s a "+ - "`slug` of at most %d characters", - name, want, by, tightest.In, tightest.Max, longestMachine, got, len(got), name, - tightest.Max-len(IdentityPrefix)-longestMachine-1)) - } - } - return problems -} diff --git a/go/catalogue/identity_bound_test.go b/go/catalogue/identity_bound_test.go index f7f27c0..7dee560 100644 --- a/go/catalogue/identity_bound_test.go +++ b/go/catalogue/identity_bound_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "encoding/json" "strings" "testing" ) @@ -61,97 +60,6 @@ func TestAnUnstatedBoundFollowsWhatTheProviderIsTold(t *testing.T) { } } -// The field reads as written and writes back the same, and refuses what says nothing. -func TestAnOffersIdentityIsParsedStrictly(t *testing.T) { - for _, raw := range []string{ - `{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`, - `{"name":"wildcard-resolution","scope":"mesh","identity":false}`, - `{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`, - } { - var o Offer - if err := json.Unmarshal([]byte(raw), &o); err != nil { - t.Fatalf("%s: %v", raw, err) - } - back, err := json.Marshal(o) - if err != nil || string(back) != raw { - t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err) - } - } - for _, raw := range []string{ - `{"name":"x","identity":true}`, - `{"name":"x","identity":{"max":20,"in":"y","most":3}}`, - } { - var o Offer - if err := json.Unmarshal([]byte(raw), &o); err == nil { - t.Errorf("accepted %s", raw) - } - } - bad := Manifest{Module: "bad", Version: "1", Provides: []Offer{ - {Name: "unsaid", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 20}}, - {Name: "tiny", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 4, In: "nothing usable"}}, - }} - raw, err := json.Marshal(bad) - if err != nil { - t.Fatal(err) - } - _, err = ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") || - !strings.Contains(err.Error(), "the shortest the mesh makes") { - t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err) - } -} - -// Refused before merge: the catalogue check judges each module's identity, on the longest machine -// name, against the bound of every provision it wants — and names the module and the slug to set. -func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) { - shelf := Shelf{ - "objects": {Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh, - Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}}, - Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}, - "photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}}, - "files": {Module: "files", Requires: []string{"s3-bucket"}}, - } - problems := IdentityProblems(shelf, 6) - if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") || - !strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) || - !strings.Contains(problems[0], "`slug` of at most 8 characters") { - t.Fatalf("one overflow, named with its remedy, was expected: %q", problems) - } - // A longer machine name refuses more: the check is about the mesh's machines, not one. - if got := IdentityProblems(shelf, 10); len(got) != 2 { - t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got) - } - // And a slug is the remedy it names. - album := shelf["photoalbum"] - album.Slug = "album" - shelf["photoalbum"] = album - if got := IdentityProblems(shelf, 6); len(got) != 0 { - t.Fatalf("a slug that fits is still refused: %q", got) - } -} - -// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a -// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's -// whole machine with it; the resolver keeps no name, so it is not refused at all. -func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) { - shelf := Shelf{ - "resolver": {Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}}, - "networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}}, - } - if CheckIdentity("laptop", "networkmanager") == nil { - t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound") - } - if got := IdentityProblems(shelf, 6); len(got) != 0 { - t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got) - } - r := Resolution{Node: "laptop", Modules: []Manifest{shelf["networkmanager"]}, - Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager", - Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}} - if got := r.Overflowing(); len(got) != 0 { - t.Fatalf("a keyless requirement is reported as overflowing: %+v", got) - } -} - // The consumer's side of the same judgement the provider's composition makes: what `status` says. func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) { bound := IdentityBound{Max: 20, In: "an S3 access key"} @@ -174,17 +82,3 @@ func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) { t.Fatalf("the overflow does not say what keeps it or the remedy: %s", said) } } - -// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one. -func TestAnIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) { - serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}} - wide := Manifest{Module: "wide", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh, - Identity: &OfferIdentity{Max: 255, In: "a client id"}}}} - if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") { - t.Fatalf("a 255-character bound on a DNS label passed: %q", got) - } - unsaid := Manifest{Module: "unsaid", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}} - if got := CheckServes(unsaid); len(got) != 0 { - t.Fatalf("the default bound (20) on a DNS label was refused: %q", got) - } -} diff --git a/go/catalogue/installation.go b/go/catalogue/installation.go index b257806..2a00b7c 100644 --- a/go/catalogue/installation.go +++ b/go/catalogue/installation.go @@ -1,14 +1,5 @@ package catalogue -import ( - "encoding/json" - "fmt" - "net" - "regexp" - "sort" - "strings" -) - // A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134). // // A module definition holds what is true of the module everywhere; what is particular to one mesh — @@ -34,203 +25,3 @@ import ( // NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name: // each name mapped to its reason. The host never sees it. const NamesOnPurpose = "names-on-purpose" - -// prose is every key whose value the mesh never reads. -var prose = map[string]bool{"why": true, "description": true} - -// Registries the world runs, which an image may name because an image reference must say where it -// is pulled from. Anything else in an image reference is a registry of some installation. -var worldsRegistries = map[string]bool{ - "docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true, - "quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true, - "mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true, - "codeberg.org": true, "cgr.dev": true, -} - -// Services the world runs that a definition may name as a policy default, the way it may name a -// public resolver: the public certificate authorities' ACME directories. Anything else a served -// fact or a file names is somebody's installation. -var worldsServices = map[string]bool{ - "acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true, - "api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true, - "acme.zerossl.com": true, -} - -// Resolvers the world runs, which a machine's resolver may forward to as a policy default. -var worldsResolvers = map[string]bool{ - "1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true, - "149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true, -} - -// hostname is a dotted name whose last label is a top-level domain a real installation would have. -// Not every dotted token: `module.json`, `index.html` and `docker.sock` are dotted and name nothing. -// Boundaries are checked by hand rather than in the pattern, because two names one character apart -// — `a.example.tld,b.example.tld` — would otherwise share the delimiter and the second would be lost. -var hostname = regexp.MustCompile( - `(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` + - `(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` + - `internal|example|tld|test|invalid)`) - -// upstreamVersion is where an artifact pinned by URL says which release it is (Artifact.Version). -var upstreamVersion = regexp.MustCompile(`^build\.artifacts\[[0-9]+\]\.version$`) - -// address is a dotted quad. -var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`) - -// isName is whether a byte may be part of a name; a match bordered by one is a longer token. -func isName(b byte) bool { - return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') -} - -// standalone are the matches of re in value that are whole tokens, not parts of a longer one. -func standalone(re *regexp.Regexp, value string) []string { - var out []string - for _, span := range re.FindAllStringIndex(value, -1) { - if span[0] > 0 && isName(value[span[0]-1]) { - continue - } - if span[1] < len(value) && isName(value[span[1]]) { - continue - } - out = append(out, value[span[0]:span[1]]) - } - return out -} - -// InstallationProblems is every value of a definition that names an installation, in the -// definition's own words: where it is, and what it names. -func InstallationProblems(m Manifest) []string { - raw, err := json.Marshal(m) - if err != nil { - return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)} - } - var tree any - if err := json.Unmarshal(raw, &tree); err != nil { - return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)} - } - var problems []string - // The module's own name is a value too: a module named after the domain it serves is a - // definition that can only be installed there (issue 134). - for _, name := range namesIn(m.Module) { - problems = append(problems, fmt.Sprintf( - "%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name)) - } - walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) { - for _, name := range namesIn(value) { - if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] { - continue - } - problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at)) - } - for _, ip := range addressesIn(value) { - // An upstream pin's version may have four dotted numbers (Owl 1.5.6.1), which is no address; a name - // in it is still judged above. Its URL is judged whole: the vendor's host goes through the artifact's - // names-on-purpose like any name a definition means (novox/hq ADR 0316). - if meant[ip] || upstreamVersion.MatchString(at) { - continue - } - problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at)) - } - }) - sort.Strings(problems) - return problems -} - -// walk visits every string in the tree with its path, the names the enclosing resource means to -// name (with a reason), and whether it is an image reference. -func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) { - switch v := node.(type) { - case map[string]any: - if declared, has := v[NamesOnPurpose].(map[string]any); has { - widened := map[string]bool{} - for name := range meant { - widened[name] = true - } - for name, reason := range declared { - if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" { - widened[strings.ToLower(name)] = true - } - } - meant = widened - } - keys := make([]string, 0, len(v)) - for k := range v { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - // Prose is a string a person reads. A key that is called `why` or `description` and holds - // anything else — a setting of that name, whose default the mesh writes — is walked like any - // other (novox/hq ADR 0262). - if _, isString := v[k].(string); (prose[k] && isString) || k == NamesOnPurpose || (at == "" && k == "module") { - continue - } - child := at + "." + k - if at == "" { - child = k - } - if s, isString := v[k].(string); isString { - visit(child, s, meant, k == "image") - continue - } - walk(v[k], child, meant, visit) - } - case []any: - for i, item := range v { - child := fmt.Sprintf("%s[%d]", at, i) - if s, isString := item.(string); isString { - visit(child, s, meant, false) - continue - } - walk(item, child, meant, visit) - } - } -} - -// namesIn is every hostname in a value that could belong to an installation. -func namesIn(value string) []string { - var out []string - for _, found := range standalone(hostname, value) { - name := strings.ToLower(found) - switch { - case strings.HasSuffix(name, ".docker.internal"): - // The container runtime's alias for its own host: every machine running it has one. - case worldsServices[name]: - // A public authority named as a policy default, true of any mesh that wants it. - case name == "example.tld", strings.HasSuffix(name, ".example.tld"), - name == "example.com", name == "example.net", name == "example.org", - strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"), - strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"), - strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"): - // Documentation names, which is what a definition's own example should use. - default: - out = append(out, name) - } - } - return out -} - -// addressesIn is every public address in a value: not a private range, loopback, link-local, the -// unspecified address, a documentation range, or a resolver the world runs. -func addressesIn(value string) []string { - var out []string - for _, found := range standalone(address, value) { - ip := net.ParseIP(found) - if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || - ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) { - continue - } - out = append(out, found) - } - return out -} - -func documentation(ip net.IP) bool { - for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} { - _, block, _ := net.ParseCIDR(cidr) - if block.Contains(ip) { - return true - } - } - return false -} diff --git a/go/catalogue/installation_test.go b/go/catalogue/installation_test.go index 563ed72..1b52013 100644 --- a/go/catalogue/installation_test.go +++ b/go/catalogue/installation_test.go @@ -1,93 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged; -// prose is not; the world's registries and resolvers are the world's; a declared exception is a -// reason a reader sees. -func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) { - m := Manifest{Module: "idp", Resources: []map[string]any{ - {"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa", - "env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}}, - {"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"}, - }, Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}} - got := strings.Join(InstallationProblems(m), "\n") - for _, want := range []string{ - "idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME", - "idp names the public address 51.15.22.9 at resources[1].content", - } { - if !strings.Contains(got, want) { - t.Errorf("missing %q in:\n%s", want, got) - } - } - for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} { - if strings.Contains(got, mustNot) { - t.Errorf("%q was reported and should not be:\n%s", mustNot, got) - } - } -} - -func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) { - m := Manifest{Module: "resolver", Resources: []map[string]any{ - {"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"}, - {"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"}, - {"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"}, - {"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"}, - }} - if got := InstallationProblems(m); len(got) != 0 { - t.Fatalf("the world's names were reported: %v", got) - } -} - -func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) { - // The federation's public directory in a homeserver's config: the world's, said so, and a name - // the map does not cover is still reported. - m := Manifest{Module: "homeserver", Resources: []map[string]any{ - {"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n", - NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}}, - }} - got := InstallationProblems(m) - if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") { - t.Fatalf("got %v", got) - } -} - -func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) { - bare := Manifest{Module: "site", Resources: []map[string]any{ - {"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"}, - }} - if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") { - t.Fatalf("an image on an installation's registry was not named: %v", got) - } - excepted := Manifest{Module: "site", Resources: []map[string]any{ - {"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc", - NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}}, - }} - if got := InstallationProblems(excepted); len(got) != 0 { - t.Fatalf("a declared exception was still reported: %v", got) - } -} - -func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) { - got := InstallationProblems(Manifest{Module: "mesh-one.be"}) - if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") { - t.Fatalf("got %v", got) - } -} - -func TestABuildContextOnASeatNamesNoForge(t *testing.T) { - m := Manifest{Module: "packager", Build: &Build{Artifacts: []Artifact{ - {Name: "server", Kind: "image", From: "Dockerfile", - Context: &ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}}, - }}} - if got := InstallationProblems(m); len(got) != 0 { - t.Fatalf("a context on a seat was reported: %v", got) - } - m.Build.Artifacts[0].Context = &ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"} - if got := InstallationProblems(m); len(got) != 1 { - t.Fatalf("a context by URL was not reported: %v", got) - } -} diff --git a/go/catalogue/invokes_test.go b/go/catalogue/invokes_test.go index 7612ad2..c43bf47 100644 --- a/go/catalogue/invokes_test.go +++ b/go/catalogue/invokes_test.go @@ -1,14 +1,13 @@ package catalogue import ( - "strings" "testing" ) // A manifest may say which tools its module calls (novox/hq ADR 0152), and the parser accepts the // two shapes the grant has: a named tool, and every tool. func TestAManifestMaySayWhatItInvokes(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"mesh-console","version":"1",` + `"invokes":["mesh-catalog.catalog_modules","*"]}`)) if err != nil { t.Fatal(err) @@ -17,15 +16,3 @@ func TestAManifestMaySayWhatItInvokes(t *testing.T) { t.Fatalf("invokes not read: %v", m.Invokes) } } - -// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than -// at the composition of the bus's user list where it would stop the file for everybody. -func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`)) - if err == nil { - t.Fatal("an invoke naming no tool was accepted") - } - if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) { - t.Fatalf("refused for the wrong reason: %v", err) - } -} diff --git a/go/catalogue/issued_credential_test.go b/go/catalogue/issued_credential_test.go index 88b235d..1b52013 100644 --- a/go/catalogue/issued_credential_test.go +++ b/go/catalogue/issued_credential_test.go @@ -1,42 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer -// names the tool that answers it and no own secret, and every other shape is refused at parse. -func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"media","version":"1", - "provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}], - "serves":{"media-api":{"port":32400}}}`)) - if err != nil { - t.Fatal(err) - } - if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" { - t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued) - } - if _, shared := m.SharedCredentialOf("media-api"); shared { - t.Fatal("an issued credential was read as an own secret shared") - } - if _, issued := m.IssuedCredentialOf("other"); issued { - t.Fatal("a provision the module does not offer reads as issued") - } - - for want, raw := range map[string]string{ - "the issuers an offer may name": `{"module":"d","version":"1", - "provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`, - "names no tool that answers it": `{"module":"d","version":"1", - "provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`, - "names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}}, - "provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`, - "the issuers an offer may name ": `{"module":"d","version":"1", - "provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`, - } { - want = strings.TrimSpace(want) - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("expected a refusal saying %q, got %v", want, err) - } - } -} diff --git a/go/catalogue/issued_outside_test.go b/go/catalogue/issued_outside_test.go index 54e5d29..1b52013 100644 --- a/go/catalogue/issued_outside_test.go +++ b/go/catalogue/issued_outside_test.go @@ -1,46 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164): -// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside -// either. An offer that says `software` keeps ADR 0305's shape. -func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"tv","version":"1", - "provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}], - "serves":{"tv-api":{"port":8989}}}`)) - if err != nil { - t.Fatal(err) - } - if !m.IssuedOutsideOf("tv-api") { - t.Fatal("the offer's credential issued outside was not read") - } - if m.IssuedOutsideOf("other") { - t.Fatal("a provision the module does not offer reads as issued outside") - } - if _, issued := m.IssuedCredentialOf("tv-api"); issued { - t.Fatal("a credential issued outside was read as issued by the software") - } - if _, shared := m.SharedCredentialOf("tv-api"); shared { - t.Fatal("a credential issued outside was read as an own secret shared") - } - if who, offered := OffersIssuedOutside(map[string]Manifest{"tv": m}, "tv-api"); !offered || who != "tv" { - t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered) - } - - for want, raw := range map[string]string{ - "names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}}, - "provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`, - "names the tool": `{"module":"d","version":"1", - "provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`, - "the issuers an offer may name": `{"module":"d","version":"1", - "provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`, - } { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("expected a refusal saying %q, got %v", want, err) - } - } -} diff --git a/go/catalogue/jail_problems_test.go b/go/catalogue/jail_problems_test.go index 800d36a..1b52013 100644 --- a/go/catalogue/jail_problems_test.go +++ b/go/catalogue/jail_problems_test.go @@ -1,49 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A jail whose pattern names twice is refused by name: fail2ban would read it as a duplicate -// capture group and refuse its whole configuration, leaving the machine with no bans at all — which -// is what happened on the control node the day this was built (novox/hq ADR 0179). -func TestAJailsPatternNamesTheHostOncePerShape(t *testing.T) { - twice := Manifest{Module: "route-proxy", Jails: []Jail{{ - Name: "route-proxy", - Failregex: `^.*(?:refused for :\d+|asked from :\d+)$`, - Jail: "backend = systemd", - }}} - problems := twice.jailProblems() - if len(problems) != 1 || !strings.Contains(problems[0], "names 2 times") { - t.Fatalf("a pattern naming twice was not refused by name: %v", problems) - } - if !strings.Contains(problems[0], "no bans at all") { - t.Errorf("the refusal does not say what it costs: %q", problems[0]) - } - - // The same two shapes, one pattern each, is how it is written — and is accepted. - split := Manifest{Module: "route-proxy", Jails: []Jail{{ - Name: "route-proxy", - Failregex: "^.*refused for :\\d+$\n ^.*asked from :\\d+$", - Jail: "backend = systemd", - }}} - if problems := split.jailProblems(); len(problems) != 0 { - t.Fatalf("one per pattern was refused: %v", problems) - } -} - -// A jail with no name, no pattern, or a name another jail of the same module already took is refused. -func TestAJailIsNamedOnceAndSaysWhatAFailureLooksLike(t *testing.T) { - m := Manifest{Module: "mailu", Jails: []Jail{ - {Name: "", Failregex: "^.*rip=$"}, - {Name: "mailu-front", Failregex: " "}, - {Name: "mailu-front", Failregex: "^.*rip=$"}, - }} - problems := strings.Join(m.jailProblems(), "\n") - for _, want := range []string{"a jail with no name", "says nothing a failed attempt looks like", "two jails called"} { - if !strings.Contains(problems, want) { - t.Errorf("missing %q in:\n%s", want, problems) - } - } -} diff --git a/go/catalogue/kept_test.go b/go/catalogue/kept_test.go index f60f8c3..1d18167 100644 --- a/go/catalogue/kept_test.go +++ b/go/catalogue/kept_test.go @@ -56,13 +56,3 @@ func TestOnlyAModuleThatKeepsGetsTheExport(t *testing.T) { } } } - -func TestKeepsMustBeAnAbsolutePath(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"root"}`)) - if err == nil || !strings.Contains(err.Error(), "keeps") { - t.Fatalf("a relative keeps path was not refused: %v", err) - } - if _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"/var/lib/mesh-vault/root"}`)); err != nil { - t.Fatalf("an absolute keeps path was refused: %v", err) - } -} diff --git a/go/catalogue/kinded_test.go b/go/catalogue/kinded_test.go index 896a87f..3afbdfa 100644 --- a/go/catalogue/kinded_test.go +++ b/go/catalogue/kinded_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -25,62 +24,6 @@ func aChannel(module, kind string) Manifest { {Name: "channel", Scope: ScopeMesh, Kind: kind}, {Name: "intake", Scope: ScopeMesh, Kind: kind}}} } -func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) { - shelf := Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), - "desk-channel": aChannel("desk-channel", "desktop")} - if got := problemsFor(t, shelf); got != "" { - t.Fatalf("two kinds were refused: %s", got) - } - for _, m := range shelf { - if got := declaredSeatProblems(m); len(got) > 0 { - t.Fatalf("%s: %v", m.Module, got) - } - } -} - -func TestASecondClaimOfOneKindIsRefused(t *testing.T) { - got := problemsFor(t, Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), - "telegram-two": aChannel("telegram-two", "telegram")}) - if !strings.Contains(got, `of kind "telegram", which telegram already claims`) { - t.Fatalf("a second holder of one kind stood: %s", got) - } -} - -func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) { - got := problemsFor(t, Shelf{"messenger": router(), "nameless": aChannel("nameless", "")}) - if !strings.Contains(got, "claims the kinded bench channel and names no kind") { - t.Fatalf("a claim without a kind stood: %s", got) - } - odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Kind: "telegram"}}} - got = problemsFor(t, Shelf{"messenger": router(), "odd": odd}) - if !strings.Contains(got, "only a kinded bench takes a kind") { - t.Fatalf("a kind on a seat that is not kinded stood: %s", got) - } - dotted := problemsFor(t, Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")}) - if !strings.Contains(dotted, "not a usable name") { - t.Fatalf("a kind that would widen a subject stood: %s", dotted) - } -} - -func TestOnlyChannelAndIntakeAreKinded(t *testing.T) { - m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}} - if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") { - t.Fatalf("another kinded bench was declared: %s", got) - } -} - -func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) { - m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "thing", Accepts: []string{"do"}, - ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}} - got := strings.Join(declaredSeatProblems(m), "; ") - for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits", - "declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} { - if !strings.Contains(got, want) { - t.Errorf("not refused: %q in %s", want, got) - } - } -} - // Two kinds on one machine are two holders, and one kind on two machines is a second claimant. func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { modules := []Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")} @@ -93,76 +36,3 @@ func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { t.Fatal("one kind held on two machines was not refused") } } - -// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any. -func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { - good := aChannel("telegram", "telegram") - good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"} - good.RunsAs = "telegram" - if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" { - t.Fatalf("the vocabulary was refused: %s", got) - } - bad := aChannel("telegram", "telegram") - bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"} - got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad}) - for _, w := range []string{`"trusted"`, `"max-length:lots"`} { - if !strings.Contains(got, w+", which channel-capabilities/1 does not have") { - t.Errorf("%s was not refused: %s", w, got) - } - } - odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}} - if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") { - t.Errorf("capabilities on a seat that is not kinded stood: %s", got) - } -} - -// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an -// account of its own — never carried by the machine's runtime, which runs as the operator's account. -func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) { - r := router() - r.RunsAs = "" - if got := problemsFor(t, Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") { - t.Errorf("a router on the machine's runtime stood: %s", got) - } - tg := aChannel("telegram", "telegram") - tg.Claims[0].Capabilities = []string{"choice", "verified-sender"} - if got := problemsFor(t, Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") { - t.Errorf("a verified channel on the machine's runtime stood: %s", got) - } - desk := aChannel("desk-channel", "desktop") - desk.Claims[0].Capabilities = []string{"choice"} - if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" { - t.Errorf("a channel proving nothing was held to it: %s", got) - } - // A kind that is `private` shows a link's code, which links an account as the operator: its holder is - // trusted with it, so it runs as its own account too (the confirmation review of 2026-10-09). - private := aChannel("desk-channel", "desktop") - private.Claims[0].Capabilities = []string{"choice", "private"} - if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": private}); !strings.Contains(got, "desk-channel must run as an account of its own") { - t.Errorf("a private channel on the machine's runtime stood: %s", got) - } -} - -func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) { - ok := Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram", - OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}, - Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}} - if got := RunsAsProblems(ok); len(got) != 0 { - t.Fatalf("a sound runs-as was refused: %v", got) - } - for want, change := range map[string]func(*Manifest){ - "never root": func(m *Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" }, - "not an account name": func(m *Manifest) { m.RunsAs = "${machine:account}" }, - "which it does not make": func(m *Manifest) { m.Resources = nil }, - "declares no own secret": func(m *Manifest) { m.OwnSecrets = nil }, - "they are the account's own": func(m *Manifest) { m.SecretsOwner = "" }, - } { - m := ok - m.Resources = append([]map[string]any(nil), ok.Resources...) - m.OwnSecrets = OwnSecrets{"broker": {Path: "/x"}} - change(&m) - if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) { - t.Errorf("want %q, got %q", want, got) - } - } -} diff --git a/go/catalogue/localnetwork_test.go b/go/catalogue/localnetwork_test.go index 4a0cd6f..f6a2e19 100644 --- a/go/catalogue/localnetwork_test.go +++ b/go/catalogue/localnetwork_test.go @@ -118,20 +118,6 @@ func TestNoLocalNetworkIsSaidAndNotWidened(t *testing.T) { } } -// `local-network` is a source a manifest may name (§1, changing ADR 0045 §3), and an unknown source is -// still refused. -func TestAManifestMayListenFromTheLocalNetwork(t *testing.T) { - if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", - "listens":[{"port":1883,"from":"local-network"}]}`)); err != nil { - t.Fatalf("a listen from the local network is refused: %v", err) - } - _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", - "listens":[{"port":1883,"from":"lan"}]}`)) - if err == nil || !strings.Contains(err.Error(), `from "lan"`) { - t.Fatalf("a source the mesh cannot render is not refused: %v", err) - } -} - // **A fourth reach** (§4, changing ADR 0138 §4): `internal` with the local networks added to what the // filter opens — the internal name, not the public one. func TestTheLocalNetworkReachIsInternalWithTheLocalNetworkAdded(t *testing.T) { diff --git a/go/catalogue/logs.go b/go/catalogue/logs.go index d20aa1c..ec93acd 100644 --- a/go/catalogue/logs.go +++ b/go/catalogue/logs.go @@ -87,34 +87,3 @@ func (l LogDeclaration) MarshalJSON() ([]byte, error) { type plain LogDeclaration return json.Marshal(plain(l)) } - -// LogProblems is what is wrong with a manifest's logs. -// -// Refused at registration, for a bucket's reason: a stream name the bus cannot hold, or a cap the -// mesh would not grant, is a module that installs, starts, and is refused on its first append. -func LogProblems(m Manifest) []string { - var problems []string - if len(m.Logs) > 0 && !stateName.MatchString(m.Module) { - problems = append(problems, fmt.Sprintf( - "%s keeps a log, and a module's name is part of its logs' names, which take one plain "+ - "name — no dot (novox/hq ADR 0297)", m.Module)) - } - seen := map[string]bool{} - for _, l := range m.Logs { - switch { - case !stateName.MatchString(l.Name): - problems = append(problems, fmt.Sprintf( - "%s keeps log %q: a log is named locally — lower-case letters, digits and hyphens, "+ - "no dot and no underscore; the mesh derives the stream (novox/hq ADR 0297)", m.Module, l.Name)) - case seen[l.Name]: - problems = append(problems, fmt.Sprintf("%s keeps log %q twice", m.Module, l.Name)) - } - seen[l.Name] = true - if l.MaxMiB != 0 && (l.MaxMiB < LogLeastMiB || l.MaxMiB > LogMostMiB) { - problems = append(problems, fmt.Sprintf( - "%s caps log %q at %d MiB; a log holds between %d and %d MiB (novox/hq ADR 0297)", - m.Module, l.Name, l.MaxMiB, LogLeastMiB, LogMostMiB)) - } - } - return problems -} diff --git a/go/catalogue/logs_test.go b/go/catalogue/logs_test.go index aeea053..381c8b0 100644 --- a/go/catalogue/logs_test.go +++ b/go/catalogue/logs_test.go @@ -2,14 +2,13 @@ package catalogue import ( "encoding/json" - "strings" "testing" ) // A module declares the logs it keeps (novox/hq ADR 0297 §1): a log by its bare name, or with its cap // in MiB; the default cap where none is said. func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-issues","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"mesh-issues","version":"1",` + `"logs":["changes",{"name":"moves","max-mib":2048}]}`)) if err != nil { t.Fatal(err) @@ -25,7 +24,7 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) { t.Fatalf("written back as %s", out) } // 1953 S-age (ADR 0565 §10): a log may keep its entries for an age, written back with it. - aged, err := ParseManifest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`)) + aged, err := decodeForTest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`)) if err != nil || aged.Logs[0].MaxAgeDays != 400 { t.Fatalf("an aged log read as %+v, %v", aged.Logs, err) } @@ -33,45 +32,8 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) { t.Fatalf("an aged log written back as %s", out) } for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`, `{"name":"a","max-age-days":3650}`} { - if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil { + if _, err := decodeForTest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil { t.Errorf("%s is inside the caps and was refused: %v", edge, err) } } } - -// The catalogue check refuses a log outside the caps, with a name that is not one plain token, with a -// field the mesh does not know, or kept by a module whose own name is not one plain token. -func TestALogIsNamedLocallyAndCapped(t *testing.T) { - for _, c := range []struct{ manifest, says string }{ - {`{"module":"a","version":"1","logs":["mesh.changes"]}`, `keeps log "mesh.changes": a log is named locally`}, - {`{"module":"a","version":"1","logs":["my_changes"]}`, `keeps log "my_changes"`}, - {`{"module":"a","version":"1","logs":["Changes"]}`, `keeps log "Changes"`}, - {`{"module":"a","version":"1","logs":["c","c"]}`, `keeps log "c" twice`}, - {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`}, - {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`}, - {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`}, - {`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days}`}, - {`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650, not 0`}, - {`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`}, - } { - _, err := ParseManifest([]byte(c.manifest)) - if err == nil { - t.Errorf("%s was accepted", c.manifest) - continue - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) - } - } -} - -// **Across the whole catalogue**: every log is named locally and capped within the mesh's caps. -func TestEveryManifestsLogsAreLocalAndCapped(t *testing.T) { - var problems []string - for _, m := range theCatalogue(t) { - problems = append(problems, LogProblems(m)...) - } - if len(problems) > 0 { - t.Fatalf("the catalogue's logs are not what ADR 0297 says:\n %s", strings.Join(problems, "\n ")) - } -} diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index c475a59..568be11 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -15,7 +15,6 @@ import ( "sort" "strconv" "strings" - "unicode/utf8" ) // Scopes a claim can have. @@ -29,27 +28,6 @@ const ( ScopeMesh = "mesh" ) -// name is what a module, a provision or a claim may be called. -// -// Constrained because these become resource identities, permission patterns and error messages, -// and a name that is valid in one and not the others is a fault found late. -// renamed is what a field used to be called, and what it is now. -// -// Kept rather than dropped once the rename is done: a manifest written against the old name is -// refused either way, and the difference is whether whoever wrote it has to go and find out why. -var renamed = map[string]string{ - // `needs` and `secrets` were both name-to-path and differed only in whose secret it was, so - // reaching for the wrong one parsed cleanly and failed somewhere else entirely. - "needs": "own-secrets", -} - -var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`) - -// toolName is what a module calls one of its tools: the sdk's tools are `catalog_modules` and -// `gitea_list_repos`, so an underscore is ordinary here and a dot is not — the dot is what separates -// the module from the tool in `.`, and a tool name carrying one would be two grants. -var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) - // Claim is a singular resource a module takes over. type Claim struct { Name string `json:"name"` @@ -409,28 +387,6 @@ func (m Manifest) CredentialFormOf(provision string) string { return "" } -// CredentialFormProblems refuses an offer that does not say its credential's form (the lead, 2026-10-29): an own -// secret, an issuer, or `form` pair or none, and `form` only where neither of the others is said. -func CredentialFormProblems(m Manifest) []string { - var problems []string - for _, o := range m.Provides { - c := o.Credential - switch { - case c == nil || (c.Own == "" && c.IssuedBy == "" && c.Form == ""): - problems = append(problems, fmt.Sprintf("%s provides %q and says no credential form: say `credential` with "+ - "`form` pair (a login of each consumer's own) or none (no credential), or the own secret or issuer it is", - m.Module, o.Name)) - case c.Form != "" && (c.Own != "" || c.IssuedBy != ""): - problems = append(problems, fmt.Sprintf("%s provides %q with `form` beside an own secret or an issuer; `form` "+ - "says only pair or none", m.Module, o.Name)) - case c.Form != "" && c.Form != CredentialFormPair && c.Form != CredentialFormNone: - problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, o.Name, - c.Form, CredentialFormPair, CredentialFormNone)) - } - } - return problems -} - // ProvisionsSharing is every provision of this module whose credential is the named own secret. func (m Manifest) ProvisionsSharing(own string) []string { var out []string @@ -1866,838 +1822,6 @@ func SecretLocal(to, local string) string { // the JSON decoder words it, or "" when it had none (novox/hq ADR 0262). func (m Manifest) UnknownField() string { return m.unknown } -func ParseManifest(raw []byte) (Manifest, error) { - return parseManifest(raw, PublishedPredefined()) -} - -// ParseManifestWith is ParseManifest judging every value that falls back to a predefined key against the list -// given — the one read from the tree the manifest is in (predefined.go, novox/hq ADR 0348 §7) — where -// ParseManifest judges against the list the mesh published. Nil is no list: a value naming a key is refused. -func ParseManifestWith(raw []byte, predefined PredefinedList) (Manifest, error) { - return parseManifest(raw, predefined) -} - -func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) { - var m Manifest - // Strictly. **An unknown key is refused**, which is the discipline the host's declaration - // parser has and manifests lacked (novox/hq 04-ISSUES/003): a `scope:` key survived in five - // manifests, read by nothing, making them appear to restrict a port and restrict nothing. - // - // "An unenforced rule is indistinguishable from a wrong one, and costs more, because people - // believe it" — and a silently-accepted key is worse than unenforced, because a reviewer - // checking whether something is restricted will find that it is, and be wrong. - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - err := decoder.Decode(&m) - if err == nil && m.unknown != "" { - // Kept aside by UnmarshalJSON for the stored catalogue's sake; registration refuses it. - err = errors.New(m.unknown) - } - if err != nil { - // A key that used to mean something says what it became. Refusing a renamed field with - // "unknown field" is correct and unhelpful: whoever wrote it knew what they meant, and - // the mesh knows what it is called now. - for was, is := range renamed { - if strings.Contains(err.Error(), `"`+was+`"`) { - return Manifest{}, fmt.Errorf( - "this manifest says %q, which is now called %q: %w", was, is, err) - } - } - return Manifest{}, fmt.Errorf("this is not a module manifest: %w", err) - } - m.predefined = predefined - - var problems []string - if !name.MatchString(m.Module) { - problems = append(problems, fmt.Sprintf( - "%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module)) - } - // A slug is a short identifier the mesh derives a login from (novox/hq ADR 0049). The same - // charset as a name; its length is judged against the bound of each provision it wants on the - // longest machine name by the catalogue check (IdentityProblems, ADR 0225), and against the - // machine it is on when its provider grants it — a slug that is fine on one machine's short name - // can overflow another's. - if m.Slug != "" && !name.MatchString(m.Slug) { - problems = append(problems, fmt.Sprintf( - "%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug)) - } - // **`amqp` is not a provision, and not a requirement** (novox/hq ADR 0131). A module that wants - // messaging wants the mesh's bus — it emits and consumes through the sdk, which the mesh hands the - // bus with the module's own credential — and the bus is whatever holds `mesh-broker`, spoken in - // whatever that holder speaks. Naming the old wire protocol asks for a specific server, and the - // only one that could answer is the one being retired. Refused here so the word cannot come back - // through a manifest. - for _, offer := range m.Provides { - if offer.Name == "amqp" { - problems = append(problems, fmt.Sprintf( - "%s provides %q, which is not a provision: the mesh's bus is whatever holds "+ - "mesh-broker, and a module provides mesh-bus to be it (novox/hq ADR 0131)", - m.Module, offer.Name)) - } - } - // **The bus's own account copies the bus and says nothing on it** (novox/hq ADR 0235). The module - // holding mesh-broker is granted the snapshot API and nothing else, so anything it declared to say - // or hear on the bus would be granted nothing — refused here rather than silently dropped. - // - // **But its tools bundle may say what it declares** (novox/hq ADR 0357): the node's runtime carries - // the bundle and is granted the events the module emits, under the module's name, as for any module it - // carries — the bus's account is not who publishes them. So emits are refused only for a bus module - // with no bundle to say them. - if _, account := m.OwnSecrets["broker"]; account && m.ClaimsSeat(BrokerSeat) { - var said []string - if len(m.EmitsAll()) > 0 && !m.shipsABundle() { - said = append(said, "emits") - } - for _, f := range []struct { - name string - n int - }{{"consumes", len(m.Consumes)}, {"uses", len(m.Uses)}, {"invokes", len(m.Invokes)}, - {"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)}, - {"streams", len(m.Streams)}} { - if f.n > 0 { - said = append(said, f.name) - } - } - if len(said) > 0 { - problems = append(problems, fmt.Sprintf( - "%s holds %s and declares a bus account, which is granted the bus's snapshot API and nothing "+ - "else (novox/hq ADR 0235); its %s would be granted nothing", m.Module, BrokerSeat, strings.Join(said, ", "))) - } - } - for _, r := range m.Requires { - if r == "amqp" { - problems = append(problems, fmt.Sprintf( - "%s requires %q, which is not a provision: a module reaches the mesh's bus through "+ - "the sdk, and depends on the mesh-broker seat, not on a protocol (novox/hq ADR 0131)", - m.Module, r)) - } - } - if m.Instances != "" && m.Instances != InstancesInterchangeable { - problems = append(problems, fmt.Sprintf( - "%s says its instances are %q; the one word is %q, for a module that is the same on every machine", - m.Module, m.Instances, InstancesInterchangeable)) - } - for _, offer := range m.Provides { - p := offer.Name - if !name.MatchString(p) { - problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p)) - } - // A provision's credential is a provision of its own, `secret-` (novox/hq ADR 0570 clause 6, the - // lead 2026-10-29): no module provides a name of that form, so the two keys never meet. - if strings.HasPrefix(p, CredentialProvisionPrefix) { - problems = append(problems, fmt.Sprintf("%s provides %q; a name starting %q is the mesh's for a provision's "+ - "credential, kept by the vault (novox/hq ADR 0570)", m.Module, p, CredentialProvisionPrefix)) - } - // A bound says what keeps the name, so the refusal it causes can say it (ADR 0225); and it - // leaves room for the shortest identity the mesh makes, or it would refuse every consumer. - if id := offer.Identity; id != nil && !id.None { - if strings.TrimSpace(id.In) == "" { - problems = append(problems, fmt.Sprintf( - "%s bounds the identities of %s's consumers without saying what keeps them: `in`", - m.Module, p)) - } - if shortest := len(IdentityPrefix) + 3; id.Max < 0 || id.Max > 0 && id.Max < shortest { - problems = append(problems, fmt.Sprintf( - "%s bounds %s's consumers' identities at %d characters, and the shortest the mesh "+ - "makes is %d", m.Module, p, id.Max, shortest)) - } - } - if c := offer.Credential; c != nil && c.IssuedBy == IssuedOutside { - // Only a person can give it (novox/hq ADR 0337 §1, issue 164): the software holds a value the mesh can - // neither make nor fetch, so the offer names nothing that would answer it — no own secret, no tool. - switch { - case c.Own != "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential issued outside the mesh and names its own secret %q too; a "+ - "person gives the value with `secret accept`, so the offer names no own secret (ADR 0337)", - m.Module, p, c.Own)) - case c.Tool != "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential issued outside the mesh and names the tool %q; only a "+ - "person gives it, so the offer names no tool (ADR 0337)", m.Module, p, c.Tool)) - } - } else if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") { - // The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own - // secret, which would be a second copy for the mesh to keep in step with the software's. - switch { - case c.IssuedBy != IssuedBySoftware: - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential issued by %q; the issuers an offer may name are %q and %q "+ - "(ADR 0305, ADR 0337)", m.Module, p, c.IssuedBy, IssuedBySoftware, IssuedOutside)) - case c.Own != "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential its software issues and names its own secret %q too; the "+ - "software's value is the one, so the offer names no own secret (ADR 0305)", m.Module, p, c.Own)) - case strings.TrimSpace(c.Tool) == "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential its software issues and names no tool that answers it: "+ - "\"tool\": \"\" (ADR 0305)", m.Module, p)) - } - } else if offer.Credential != nil { - own, declared := m.OwnSecrets[offer.Credential.Own] - switch { - case offer.Credential.Own == "" && offer.Credential.Form != "": - // The form alone, pair or none (CredentialFormProblems judges it). - case offer.Credential.Own == "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential that names no own secret", m.Module, p)) - case !declared: - problems = append(problems, fmt.Sprintf( - "%s provides %q with its own secret %q as the credential, and declares no such secret", - m.Module, p, offer.Credential.Own)) - case own.Taken == "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with its own secret %q as the credential every consumer receives, so "+ - "the secret must say how the module takes it: \"taken\": \"at-start\" or \"applied\" (ADR 0158)", - m.Module, p, offer.Credential.Own)) - } - } - if instead, generic := engineGeneric[p]; generic { - // A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing - // the role means a requirement for it matches any engine, resolves as satisfied, and - // fails on the first query — with nothing pointing back at the match. - problems = append(problems, fmt.Sprintf( - "%s provides %q, which hides which engine it is: a requirement for %q would match "+ - "any of them and fail on the first query. Name the engine — %s", - m.Module, p, p, instead)) - } - if s := offer.At(); s != ScopeNode && s != ScopeMesh { - // Site scope is meaningful for a claim — one DHCP server per segment — and is not - // yet meaningful for a provision, because nothing knows how to reach "the one at my - // site". Refused rather than silently treated as mesh-wide. - problems = append(problems, fmt.Sprintf( - "%s provides %q at scope %q; a provision is %q or %q", - m.Module, p, s, ScopeNode, ScopeMesh)) - } - switch { - case offer.Reach == "": - case offer.Reach != ReachMachine: - // The one reach a provision has (novox/hq ADR 0208): a provision reached over the private - // network is mesh scope, and the world reaches nothing but a name. - problems = append(problems, fmt.Sprintf( - "%s provides %q with reach %q; a provision's reach is %q or nothing", - m.Module, p, offer.Reach, ReachMachine)) - case offer.At() != ScopeNode: - problems = append(problems, fmt.Sprintf( - "%s provides %q at scope %q with the machine's reach; a provision usable only on its own "+ - "machine is node-scoped (novox/hq ADR 0208)", m.Module, p, offer.At())) - } - if p == m.Module { - // Harmless and worth saying: a module always provides its own name, so writing it - // suggests the author expected it not to. - problems = append(problems, fmt.Sprintf( - "%s provides its own name already; listing it says nothing", m.Module)) - } - } - for _, r := range m.Requires { - if !name.MatchString(r) { - problems = append(problems, fmt.Sprintf("%q is not a usable name to require", r)) - } - if r == m.Module { - problems = append(problems, fmt.Sprintf("%s requires itself", m.Module)) - } - } - // What it may call an event, and what it may ask to hear (events.go). Checked here because a - // module whose event names are wrong installs, starts, connects and reacts to nothing, with - // every log line saying it is fine (novox/hq 04-ISSUES/127). - problems = append(problems, EventProblems(m)...) - // And what it may call its state, and whose it may read (state.go, novox/hq ADR 0201). - problems = append(problems, StateProblems(m)...) - // And what it may call its logs, and how large it may ask them to grow (logs.go, novox/hq ADR 0297). - problems = append(problems, LogProblems(m)...) - // And the streams it owns, and who may hear them (streams.go, novox/hq ADR 0363). - problems = append(problems, StreamProblems(m)...) - problems = append(problems, WorkQueueStreamProblems(m)...) - // And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262), or, written in the - // config form, the whole declaration of each value (config.go, novox/hq ADR 0348). - if m.DeclaresConfig() { - problems = append(problems, ConfigProblems(m)...) - } else { - problems = append(problems, SettingProblems(m)...) - problems = append(problems, TypedSettingProblems(m)...) - } - // And what it requires optionally, and the files it delivers only while bound (novox/hq ADR 0330). - problems = append(problems, OptionalProblems(m)...) - // And which setting keys each mergeable file takes (setting_takes.go, novox/hq ADR 0326). - problems = append(problems, TakenSettingsProblems(m)...) - wellFormed := true - for _, c := range m.Claims { - if !name.MatchString(c.Name) { - problems = append(problems, fmt.Sprintf("%q is not a usable claim name", c.Name)) - wellFormed = false - } - switch c.At() { - case ScopeNode, ScopeSite, ScopeMesh: - default: - problems = append(problems, fmt.Sprintf( - "%s claims %s at scope %q; a claim is held per node, per site or per mesh", - m.Module, c.Name, c.Scope)) - wellFormed = false - } - } - // Against the seats the mesh defines (novox/hq ADR 0110), once every claim is at least a name - // and a scope — a malformed claim is refused for that, not a second time for being unknown. - if wellFormed { - problems = append(problems, claimProblems(m)...) - } - // What one manifest can be judged on: a declaration's shape, its scope, and the reserved - // prefix. Whether a seat anybody names exists, and whether a holder answers for it, are - // facts about the catalogue and are checked at registration (CatalogueProblems). - problems = append(problems, declaredSeatProblems(m)...) - problems = append(problems, RunsAsProblems(m)...) - problems = append(problems, ProcessBusProblems(m)...) - problems = append(problems, seatEventProblems(m)...) - problems = append(problems, ReadsHolderProblems(m)...) - problems = append(problems, FilesHolderProblems(m)...) - if m.Computed != "" && len(m.Resources) > 0 { - // One or the other. A module that both ships files and has them computed would leave - // nobody able to say where a given file came from. - problems = append(problems, fmt.Sprintf( - "%s has resources of its own and says they are computed by %q; it is one or the other", - m.Module, m.Computed)) - } - for to, values := range m.Contributes { - if !name.MatchString(to) { - problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to)) - } - if len(values) == 0 { - // An empty contribution is either a mistake or a requirement written the long way - // round, and both are better said plainly. - problems = append(problems, fmt.Sprintf( - "%s contributes nothing to %q; if it only needs one, require it", m.Module, to)) - } - } - for to, locals := range m.ContributesMany { - if !name.MatchString(to) { - problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to)) - } - if len(locals) == 0 { - problems = append(problems, fmt.Sprintf( - "%s contributes nothing to %q; if it only needs one, require it", m.Module, to)) - } - for local, values := range locals { - if !name.MatchString(local) { - problems = append(problems, fmt.Sprintf( - "%s contributes to %q under %q, which is not a usable name", m.Module, to, local)) - } - if len(values) == 0 { - problems = append(problems, fmt.Sprintf( - "%s contributes nothing to %q under %q", m.Module, to, local)) - } - } - } - problems = append(problems, m.Build.problems(m.Module)...) - // A file's text in a template file of the module's repository (novox/hq ADR 0349 §2): only a file, never beside - // content, only under files/, and packed by the module's build. - problems = append(problems, templateProblems(m)...) - problems = append(problems, movedProblems(m)...) - problems = append(problems, collectionProblems(m)...) - problems = append(problems, undeliveredBundles(m)...) - // **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029). - // - // Building publishes to the store, and the builder will not start without one. So a module - // that provides the store and also builds something asks the mesh to put an artifact into the - // thing that artifact is needed to create. - // - // It is the question the foundation record asks of every candidate — can it grant itself the - // thing it provides? The store cannot create its own database, the broker cannot create its - // own virtual host, and a registry cannot grant itself a repository. Such a module names its - // image, exactly as the bundle names the three a first node starts from. - // - // Refused here because the alternative is a build that never returns, on a mesh new enough - // that nobody is watching it yet. - if m.Build != nil && len(m.Bundles) > 0 { - // The output of a build, written beside the build that produces it (ADR 0175). A resource - // naming a digest beside an `artifact` would be the same mistake, and is caught the same way: - // what the mesh derives, a repository does not state. - problems = append(problems, fmt.Sprintf( - "%s writes `bundles` beside its build. The mesh derives that from what the build "+ - "produced; a manifest states `build.artifacts` and nothing about what came out", - m.Module)) - } - if m.Build != nil && len(m.Build.Artifacts) > 0 { - for _, o := range m.Offers() { - if o != ArtifactStoreProvision { - continue - } - problems = append(problems, fmt.Sprintf( - "%s provides %q and also builds %d artifact(s), which cannot both be true: "+ - "building publishes to the artifact store, so this asks the mesh to put an "+ - "artifact into the thing that artifact is needed to create. A module that "+ - "provides the store names its image instead. One that wants more beside it "+ - "— an interface, a tool server — is a second module, mirrored in the "+ - "ordinary way once this one is running", - m.Module, ArtifactStoreProvision, len(m.Build.Artifacts))) - } - } - for to := range m.Serves { - var offered bool - for _, o := range m.Offers() { - if o == to { - offered = true - } - } - if !offered { - problems = append(problems, fmt.Sprintf( - "%s serves %q to whoever requires it, and does not provide it", m.Module, to)) - } - } - // A served value may be derived for the consumer it is served to (novox/hq ADR 0202). Read - // here, where the definition is, rather than when somebody first requires it: a rule that - // would be refused at the first consumer is wrong from the moment it is written. - problems = append(problems, CheckServes(m)...) - for to, where := range m.Binds { - if !placedOrAbsolute(where) { - problems = append(problems, fmt.Sprintf( - "%s binds %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where)) - } - var wanted bool - for _, w := range m.Wants() { - if w == to { - wanted = true - } - } - if !wanted { - // Being told about something you never asked for would write a file describing a - // machine this one has no business talking to. - problems = append(problems, fmt.Sprintf( - "%s binds %q and does not require it", m.Module, to)) - } - } - if f := m.Filtering; f != nil && strings.TrimSpace(f.Into) == "" { - problems = append(problems, fmt.Sprintf( - "%s asks for the computed rule set and does not say where to put it", m.Module)) - } - for _, l := range m.Listens { - if l.Port < 1 || l.Port > 65535 { - problems = append(problems, fmt.Sprintf( - "%s listens on port %d, which is not a port", m.Module, l.Port)) - } - switch l.From { - case FromMesh, FromEverywhere, FromMachine, FromLocalNetwork: - case "": - // The fault this field exists to prevent. A rule with no source is open, and a - // manifest that omitted it would read as a restriction and be none. - problems = append(problems, fmt.Sprintf( - "%s listens on %d and does not say from where; it is %q, %q, %q or %q", - m.Module, l.Port, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork)) - default: - problems = append(problems, fmt.Sprintf( - "%s listens on %d from %q; it is %q, %q, %q or %q", - m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork)) - } - if p := l.At(); p != "tcp" && p != "udp" { - problems = append(problems, fmt.Sprintf( - "%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p)) - } - } - problems = append(problems, invokeProblems(m)...) - problems = append(problems, waitProblems(m)...) - problems = append(problems, replacesProblems(m)...) - problems = append(problems, UnitSettingProblems(m)...) - problems = append(problems, OwnerSettingProblems(m)...) - problems = append(problems, GroupSettingProblems(m)...) - problems = append(problems, endpointNameProblems(m)...) - problems = append(problems, RouteProblems(m)...) - for _, port := range m.Guards { - if port < 1 || port > 65535 { - problems = append(problems, fmt.Sprintf( - "%s guards port %d, which is not a port", m.Module, port)) - } - } - if c := m.Certificate; c != nil { - if !strings.HasPrefix(c.Into, "/") { - problems = append(problems, fmt.Sprintf( - "%s wants its certificate at %q, which is not an absolute path", m.Module, c.Into)) - } - if c.Authority != "" && !strings.HasPrefix(c.Authority, "/") { - problems = append(problems, fmt.Sprintf( - "%s wants the authority at %q, which is not an absolute path", - m.Module, c.Authority)) - } - } - // **A module may not declare an action, and this is where it is said** (novox/hq ADR 0005). - // - // The host already refuses one, correctly and for the right reason: the link may not carry a - // command to run, and that bound is what limits a compromised control plane to shapes it - // cannot turn into arbitrary code. But a module's resources reach a machine over the link, so - // a manifest carrying an action was accepted here, stored, resolved, planned and pushed — and - // refused on the machine, in the host's log, with nothing connecting it back to the manifest - // that caused it. - // - // That is the same failure as the network shape earlier today: the refusal was right, arrived - // far from its cause, and nobody was reading the log. A rule enforced only at the far end is - // enforced; it is just not usable. - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "action" { - continue - } - problems = append(problems, fmt.Sprintf( - "%s declares %v, which is an action, and a module may not: the link may not carry a "+ - "command to run (novox/hq ADR 0005). A module that needs something done ships a "+ - "program that reads what the mesh delivered and reconciles", - m.Module, r["id"])) - } - // **What a process replaces is something the module no longer declares** (novox/hq issue 213). - // The host keeps it running until the process is, then removes it: so it is named by the id the - // module used to give it, it is never a resource the module still declares — that would be - // applied and removed by one declaration — and only a process that stays up has anything to - // hand over to. Said here, near the author, as the host would refuse it far away. - ids := map[string]bool{} - for _, r := range m.Resources { - ids[fmt.Sprint(r["id"])] = true - } - for _, r := range m.Resources { - raw, present := r["replaces"] - if !present { - continue - } - if fmt.Sprint(r["type"]) != "process" { - problems = append(problems, fmt.Sprintf( - "%s: %v says what it replaces, and only a process does", m.Module, r["id"])) - continue - } - if once, _ := r["run-once"].(bool); once || r["schedule"] != nil { - problems = append(problems, fmt.Sprintf( - "%s: %v replaces something and runs once or on a schedule — only a process that stays "+ - "up is there a moment later to hand over to", m.Module, r["id"])) - } - list, ok := raw.([]any) - if !ok { - problems = append(problems, fmt.Sprintf( - "%s: %v replaces %v; replaces is a list of the ids this module no longer declares", - m.Module, r["id"], raw)) - continue - } - for _, item := range list { - id, ok := item.(string) - switch { - case !ok || strings.TrimSpace(id) == "": - problems = append(problems, fmt.Sprintf( - "%s: %v replaces %v, which is not an id", m.Module, r["id"], item)) - case strings.Contains(id, "."): - problems = append(problems, fmt.Sprintf( - "%s: %v replaces %q; a process replaces only a resource of its own module, named "+ - "by its own id", m.Module, r["id"], id)) - case ids[id]: - problems = append(problems, fmt.Sprintf( - "%s: %v replaces %q, which this module still declares", m.Module, r["id"], id)) - } - } - } - // **A module that prepares its state must have code the mesh can run** (novox/hq ADR 0135). The - // preparation is the module's own program in its preparation mode, so it is derived from the - // resource that runs that program — and a module declaring none has asked for something the mesh - // cannot compose. Said here, where the manifest is read, rather than by a declaration that - // quietly prepares nothing. - if m.Prepares && preparationTarget(m) == "" { - problems = append(problems, fmt.Sprintf( - "%s says it prepares its state, and declares no container or process running an artifact it built — "+ - "the preparation is this module's own program, so there has to be one for the mesh to "+ - "run it in", m.Module)) - } - // **A run-once container is a step the host runs to completion** (novox/hq ADR 0052). It is a - // boolean modifier on the container shape — the host runs the container, requires it to exit 0, - // and starts whatever the declaration places after it only once it has. A value that is not a - // boolean is refused here rather than only on the machine, for the same near-versus-far reason - // the action ban above records. A run-once step may name what it reads under restart-on: for a - // step the word means *run again* when one of those changed, which is how a fact fetched from a - // provider is fetched again when the provider moved (novox/hq ADR 0099). - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "container" { - continue - } - var runOnce bool - if raw, present := r["run-once"]; present { - once, ok := raw.(bool) - if !ok { - problems = append(problems, fmt.Sprintf( - "%s declares run-once on %v as a %T; run-once is true or false", - m.Module, r["id"], raw)) - } else { - runOnce = once - } - } - // **A scheduled container runs on a recurring cadence** (novox/hq ADR 0053), the recurring - // twin of run-once. The control plane carries the field to the host unchanged (the resolver - // copies every key of a resource, so `schedule` reaches the rendered declaration on its own); - // what belongs here is refusing, near its author, a value the host would only refuse far away. - // Three things: a value that is not a string, a string that is not a valid cron, and the pair - // run-once + schedule — a container runs once, or on a cadence, or stays up, never two. - if raw, present := r["schedule"]; present { - cron, ok := raw.(string) - switch { - case !ok: - problems = append(problems, fmt.Sprintf( - "%s declares schedule on %v as a %T; a schedule is a five-field cron string", - m.Module, r["id"], raw)) - default: - if err := validateCron(cron); err != nil { - problems = append(problems, fmt.Sprintf( - "%s declares schedule %q on %v, which is not a valid cron: %v", - m.Module, cron, r["id"], err)) - } - if runOnce { - problems = append(problems, fmt.Sprintf( - "%s declares %v as both run-once and schedule; a container runs once and "+ - "gates, or on a cadence, or stays up — never two (novox/hq ADR 0053)", - m.Module, r["id"])) - } - } - } - // **A scheduled step may hold this module's own containers still while it runs** - // (novox/hq ADR 0189). What the host judges is the declaration it receives — whether each - // id is a container placed on that machine; what belongs here is what only the definition - // shows: that the ids are this module's, that they are containers, and that the step is - // scheduled. A module naming a neighbour's container would be a module that can stop the - // mesh, and the manifest is where that is visible. - problems = append(problems, whileStoppedProblems(m, r, hasSchedule(r))...) - } - for name, own := range m.OwnSecrets { - // **A family is issued outside the mesh, and lands one file per member** (novox/hq ADR 0283): the mesh - // never makes a member, so a family the mesh may make would be one nothing ever fills. - if IsFamily(name) { - if !memberRest.MatchString(strings.TrimSuffix(FamilyPrefix(name), "-")) { - problems = append(problems, fmt.Sprintf( - "%s declares the secret family %q, whose prefix is not a name", m.Module, name)) - } - if own.IssuedBy != IssuedOutside { - problems = append(problems, fmt.Sprintf( - "%s declares the secret family %q without \"issued-by\": %q; the mesh never makes a "+ - "member of a family, so only a party outside the mesh can fill one (novox/hq ADR 0283)", - m.Module, name, IssuedOutside)) - } - if strings.Count(own.Path, "*") != 1 { - problems = append(problems, fmt.Sprintf( - "%s keeps the secret family %q at %q, which does not hold exactly one *: each member lands "+ - "where the * is replaced by its name (novox/hq ADR 0283)", m.Module, name, own.Path)) - } - for other := range m.OwnSecrets { - if other != name && !IsFamily(other) { - if rest := strings.TrimPrefix(other, FamilyPrefix(name)); rest != other && memberRest.MatchString(rest) { - problems = append(problems, fmt.Sprintf( - "%s declares the secret %q, which is also a member of its family %q — a name names one", - m.Module, other, name)) - } - } - } - } else if strings.Contains(name, "*") { - problems = append(problems, fmt.Sprintf( - "%s declares the secret %q: a * only ends a family's name, as \"-*\"", m.Module, name)) - } - if !placedOrAbsolute(own.Path) { - problems = append(problems, fmt.Sprintf( - "%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, own.Path)) - } - if name == "" { - problems = append(problems, m.Module+" needs a secret with no name") - } - if own.Taken != "" && own.Taken != TakenAtStart && own.Taken != TakenApplied { - problems = append(problems, fmt.Sprintf( - "%s says its secret %q is taken %q; a secret is taken %q (read when the module starts) "+ - "or %q (applied by the module's own code to a backend that takes it once)", - m.Module, name, own.Taken, TakenAtStart, TakenApplied)) - } - if own.IssuedBy != "" && own.IssuedBy != IssuedOutside { - problems = append(problems, fmt.Sprintf( - "%s says its secret %q is issued by %q; a secret says %q when a party outside the mesh "+ - "issues it — a vendor's key, a bot's token, a licence — and says nothing when the mesh "+ - "may make it (novox/hq ADR 0228)", - m.Module, name, own.IssuedBy, IssuedOutside)) - } - problems = append(problems, ownSecretPromptProblems(m, name, own.Prompt, IsFamily(name))...) - problems = append(problems, ownSecretOwnerProblems(m, name, own.Owner)...) - } - localOf := map[string]string{} - for _, to := range m.SecretRequirements() { - if _, plain := m.Secrets[to]; plain { - if _, also := m.SecretsMany[to]; also { - problems = append(problems, fmt.Sprintf( - "%s keeps the credential for %q both as one file and as several", m.Module, to)) - } - } - for _, f := range m.SecretFiles(to) { - if !placedOrAbsolute(f.Path) { - problems = append(problems, fmt.Sprintf( - "%s keeps the credential for %q at %q, which is neither an absolute path nor a placed one", - m.Module, SecretLocal(to, f.Local), f.Path)) - } - if f.Local != "" && !name.MatchString(f.Local) { - problems = append(problems, fmt.Sprintf( - "%s keeps a credential for %q under %q, which is not a usable name", - m.Module, to, f.Local)) - } - // A local name is what `${secret:}` says, so it may not be another requirement's - // name, another requirement's local name, or one of the module's own secrets — the - // file would hold the wrong credential while every check passed. - if f.Local != "" { - if other, taken := localOf[f.Local]; taken && other != to { - problems = append(problems, fmt.Sprintf( - "%s keeps credentials for %q and %q both under %q — a local name names one", - m.Module, other, to, f.Local)) - } - localOf[f.Local] = to - if _, own := m.OwnSecrets[f.Local]; own { - problems = append(problems, fmt.Sprintf( - "%s keeps a credential for %q under %q, which is also one of its own secrets", - m.Module, to, f.Local)) - } - for _, w := range m.Wants() { - if w == f.Local { - problems = append(problems, fmt.Sprintf( - "%s keeps a credential for %q under %q, which is also something it requires", - m.Module, to, f.Local)) - } - } - } - } - if len(m.SecretsMany[to]) == 0 && m.Secrets[to] == "" { - if _, many := m.SecretsMany[to]; many { - problems = append(problems, fmt.Sprintf( - "%s keeps the credential for %q as several files and names none", m.Module, to)) - } - } - var wanted bool - for _, w := range m.Wants() { - if w == to { - wanted = true - } - } - if !wanted { - problems = append(problems, fmt.Sprintf( - "%s wants the credential for %q and does not require it", m.Module, to)) - } - } - for to, where := range m.Grants { - if !placedOrAbsolute(where) { - problems = append(problems, fmt.Sprintf( - "%s grants %q into %q, which is neither an absolute path nor a placed one", m.Module, to, where)) - } - var offered bool - for _, o := range m.Offers() { - if o == to { - offered = true - } - } - if !offered { - problems = append(problems, fmt.Sprintf( - "%s grants %q to its consumers and does not provide it", m.Module, to)) - } - } - if m.Keeps != "" && !strings.HasPrefix(m.Keeps, "/") { - problems = append(problems, fmt.Sprintf( - "%s keeps the operator-sealed secrets at %q, which is not an absolute path", m.Module, m.Keeps)) - } - for to, where := range m.Receives { - if !name.MatchString(to) { - problems = append(problems, fmt.Sprintf("%q is not a usable name to receive", to)) - } - if !placedOrAbsolute(where) { - problems = append(problems, fmt.Sprintf( - "%s receives %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where)) - } - var offered bool - for _, o := range m.Offers() { - if o == to { - offered = true - } - } - if !offered { - // Receiving contributions to something you do not provide would create a file nobody - // ever writes to, on a machine where nothing asked for it. - problems = append(problems, fmt.Sprintf( - "%s receives contributions to %q and does not provide it", m.Module, to)) - } - } - for _, a := range m.Accesses { - if a.ID == "" && a.Path == "" { - problems = append(problems, fmt.Sprintf( - "%s declares an access with neither an id nor a path — an id, which the assignment places", - m.Module)) - } - if a.ID != "" && !accessRef.MatchString("${access:"+a.ID+"}") { - problems = append(problems, fmt.Sprintf( - "%s accesses %q; an access id is lowercase letters, digits and dashes", m.Module, a.ID)) - } - if a.Path != "" && !strings.HasPrefix(a.Path, "/") { - problems = append(problems, fmt.Sprintf( - "%s accesses %q, which is not an absolute path", m.Module, a.Path)) - } - switch a.At() { - case AccessRead, AccessReadWrite: - default: - problems = append(problems, fmt.Sprintf( - "%s accesses %q at mode %q; an access is %q or %q", - m.Module, a.Path, a.Mode, AccessRead, AccessReadWrite)) - } - } - - // **A container may not mount a path the module never declared** (novox/hq 04-ISSUES/026, - // ADR 0091). A bind mount whose source does not exist is created by the container runtime, as - // root, with whatever mode it picks — so `owner` and `mode` never reach the directory holding - // the module's data, and the rule that keeps data when a module goes away (ADR 0030) does not - // cover it, because the mesh has never heard of it. - // - // Three things declare a path, and they are the three kinds of thing a path can be: - // - the module's own: a directory or file resource, or where a secret, a grant or a - // contribution lands — created and owned by the mesh for this module; - // - the operator's: an `accesses` entry (ADR 0051) — pre-existing, shared, granted for use; - // - the machine's: a facility a declared capability grants, such as the container runtime's - // socket. It exists, the machine owns it, and declaring it as the module's own directory - // would be a lie the host would act on — which is why the first version of this check was - // withdrawn: it refused the builder. - // - // Checked here rather than on the machine because the machine cannot tell the difference: by - // the time it sees the mount it is being asked to create the directory, which it can do. - problems = append(problems, m.undeclaredMounts()...) - problems = append(problems, m.unknownDirRefs()...) - problems = append(problems, m.unknownAccessRefs()...) - problems = append(problems, m.resolvedPathProblems()...) - problems = append(problems, m.jailProblems()...) - // What a module adds to the account's environment and to the login shell, and the holder's - // placeholders for them (novox/hq ADR 0203, ADR 0204) — here, so the catalogue check refuses - // them in the words registration does. - problems = append(problems, m.environmentProblems()...) - problems = append(problems, m.shellProblems()...) - problems = append(problems, m.contributionPlaceholderProblems()...) - problems = append(problems, m.seatContributionProblems()...) - problems = append(problems, m.placementProblems()...) - problems = append(problems, m.dataProblems()...) - problems = append(problems, m.upgradeProblems()...) - problems = append(problems, m.unhealthyProblems()...) - - for i, r := range m.Resources { - id, _ := r["id"].(string) - if id == "" { - problems = append(problems, fmt.Sprintf("resource %d has no id", i)) - } - if _, ok := r["type"].(string); !ok { - problems = append(problems, fmt.Sprintf("resource %q has no type", id)) - } - } - - problems = append(problems, zoneProblems(m)...) - // How each long-running resource is ready (novox/hq ADR 0240 rule 2): said near its author, in the - // words the node-engine would refuse it in far away. - problems = append(problems, healthProblems(m)...) - // And whether a container is busy (novox/hq ADR 0335), likewise. - problems = append(problems, busyProblems(m)...) - if m.DeclaresConfig() { - // Said in the words it was written in: the rules shared with the settings form say ${setting:} - // and takes-settings. - for i, p := range problems { - problems[i] = inConfigWords(p) - } - problems = append(problems, MixedFormProblems(m)...) - } - if len(problems) > 0 { - sort.Strings(problems) - return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s", - strings.Join(problems, "\n - ")) - } - return m, nil -} - // Offers is everything this module can satisfy: its own name, and what it provides. func (m Manifest) Offers() []string { out := []string{m.Module} @@ -2776,173 +1900,6 @@ func (m Manifest) MachineSide(port int) (at int, mayAssign bool) { return port, false } -// facilitiesOf is what each capability lets a container mount: paths the machine owns and a module -// is granted the use of by declaring the capability, never by declaring them as its own. -var facilitiesOf = map[string][]string{ - // Both spellings: /var/run is a link to /run on every machine the mesh runs on. - "container-runtime": {"/var/run/docker.sock", "/run/docker.sock"}, - // The virtualisation daemon's socket, for the lab (novox/hq ADR 0172): it raises machines there. - "virtualisation": {"/var/lib/incus/unix.socket"}, -} - -// jailProblems is every jail this module declares that the machine's intrusion prevention would -// refuse (novox/hq ADR 0179). -// -// **Because one bad pattern stops every jail, not its own.** fail2ban expands `` into a named -// capture group, so a pattern naming it twice is a duplicate group name, and the daemon refuses the -// whole configuration and exits — the machine keeps no bans at all, for any jail, including the one -// watching its ssh. Caught live on the control node the day this was built, where a proxy's pattern -// matched two shapes of refusal in one line. A pattern matches one shape; several shapes are several -// patterns, one per line, as fail2ban's own filters are written. -func (m Manifest) jailProblems() []string { - var problems []string - seen := map[string]bool{} - for _, j := range m.Jails { - switch { - case strings.TrimSpace(j.Name) == "": - problems = append(problems, m.Module+" declares a jail with no name") - case seen[j.Name]: - problems = append(problems, m.Module+" declares two jails called "+strconv.Quote(j.Name)) - } - seen[j.Name] = true - if strings.TrimSpace(j.Failregex) == "" { - problems = append(problems, m.Module+"'s jail "+strconv.Quote(j.Name)+" says nothing a failed attempt looks like") - } - for _, line := range strings.Split(j.Failregex, "\n") { - if strings.TrimSpace(line) == "" { - continue - } - if n := strings.Count(line, ""); n > 1 { - problems = append(problems, fmt.Sprintf("%s's jail %s names %d times in one pattern; "+ - "fail2ban reads it as one capture group and refuses the whole configuration, so the machine "+ - "keeps no bans at all — write one pattern per shape, each naming once", - m.Module, strconv.Quote(j.Name), n)) - } - } - } - return problems -} - -// undeclaredMounts is every bind-mount source no declaration covers — see the check above. -func (m Manifest) undeclaredMounts() []string { - declared := map[string]bool{} - claim := func(p string) { - if strings.HasPrefix(p, "/") { - declared[strings.TrimRight(p, "/")] = true - } - } - for _, r := range m.Resources { - switch fmt.Sprint(r["type"]) { - case "directory", "file": - claim(fmt.Sprint(r["path"])) - } - } - for _, own := range m.OwnSecrets { - claim(own.Path) - } - for _, to := range m.SecretRequirements() { - for _, f := range m.SecretFiles(to) { - claim(f.Path) - } - } - for _, where := range m.Receives { - claim(where) - } - for _, where := range m.Grants { - claim(where) - } - for _, where := range m.Binds { - claim(where) - } - for _, a := range m.Accesses { - claim(a.Path) - } - for _, c := range m.Capabilities { - for _, p := range facilitiesOf[c] { - claim(p) - } - } - // The file a filter module's rule set is written to is declared by `filtering.into`: the mesh - // writes it, the module loads it, and the module's runtime may read it back to reload the - // mesh's own table (novox/hq ADR 0170). - if m.Filtering != nil { - claim(m.Filtering.Into) - } - // Under a declared directory is declared: a module that says where its data lives has said so - // for what it puts inside. - covers := func(path string) bool { - for at := path; strings.HasPrefix(at, "/"); { - if declared[at] { - return true - } - cut := strings.LastIndex(at, "/") - if cut <= 0 { - return false - } - at = at[:cut] - } - return false - } - var problems []string - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "container" { - continue - } - mounts, _ := r["volumes"].([]any) - for _, v := range mounts { - from, _, _ := strings.Cut(fmt.Sprint(v), ":") - if !strings.HasPrefix(from, "/") || covers(strings.TrimRight(from, "/")) { - continue - } - problems = append(problems, fmt.Sprintf( - "%s mounts %q into %v, and nothing in %s declares it. A bind mount the module did "+ - "not declare is created by the container runtime as root, so the module's own "+ - "owner and mode do not reach the directory that holds its data, and the rule "+ - "that keeps data when a module goes away (novox/hq ADR 0030) does not cover it. "+ - "Declare it: a directory resource if it is the module's, an `accesses` entry if "+ - "it is the operator's, or the capability that grants it if it is the machine's", - m.Module, from, r["id"], m.Module)) - } - } - return problems -} - -// endpointName is what an endpoint may be called: lowercase letters, digits and dashes, starting -// with a letter. The same shape a label has, because both end up in something a person types. -var endpointName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - -// endpointNameProblems holds a module's endpoint names to being usable as references (novox/hq ADR -// 0138). -// -// **Unique, because the point of a name is that it identifies one thing.** Two endpoints called the -// same would make an assignment that configures one silently configure whichever the mesh read last -// — the shape of fault this repository keeps finding, where a declaration appears to say something -// and says something else. -func endpointNameProblems(m Manifest) []string { - var problems []string - seen := map[string]int{} - for _, l := range m.Listens { - name := strings.TrimSpace(l.Name) - if name == "" { - continue - } - if !endpointName.MatchString(name) { - problems = append(problems, fmt.Sprintf( - "%s calls the endpoint on port %d %q; a name is lowercase letters, digits and "+ - "dashes, starting with a letter", m.Module, l.Port, l.Name)) - continue - } - if before, already := seen[name]; already { - problems = append(problems, fmt.Sprintf( - "%s calls both port %d and port %d %q, so anything naming that endpoint could mean "+ - "either", m.Module, before, l.Port, name)) - continue - } - seen[name] = l.Port - } - return problems -} - // EndpointPort is the port of the endpoint a module calls this, and whether it has one. func EndpointPort(m Manifest, name string) (int, bool) { want := strings.TrimSpace(name) @@ -2961,107 +1918,6 @@ func EndpointPort(m Manifest, name string) (int, bool) { // make it this command, never a script. const MaxReplaced = 80 -// replacesProblems judges what a module says its tools replace (novox/hq ADR 0245): each key a tool it -// declares, each entry a command line of one line, short, and said once. -func replacesProblems(m Manifest) []string { - var problems []string - tools := map[string]bool{} - for _, t := range m.Tools { - tools[t] = true - } - keys := make([]string, 0, len(m.Replaces)) - for k := range m.Replaces { - keys = append(keys, k) - } - sort.Strings(keys) - for _, tool := range keys { - if !tools[tool] { - problems = append(problems, fmt.Sprintf("%s says what %q replaces, and declares no such tool: "+ - "a module says it for a tool in its `tools`; a seat's verb says it in the seat's definition "+ - "(novox/hq ADR 0245)", m.Module, tool)) - continue - } - if len(m.Replaces[tool]) == 0 { - problems = append(problems, fmt.Sprintf("%s says %s replaces nothing: leave it out", m.Module, tool)) - } - seen := map[string]bool{} - for _, c := range m.Replaces[tool] { - c = strings.TrimSpace(c) - switch { - case c == "": - problems = append(problems, fmt.Sprintf("%s: %s replaces an empty command", m.Module, tool)) - case strings.ContainsAny(c, "\n\r"): - problems = append(problems, fmt.Sprintf("%s: %s replaces %q, which is more than one line", m.Module, tool, c)) - case len(c) > MaxReplaced: - problems = append(problems, fmt.Sprintf("%s: %s replaces %q, longer than %d characters: "+ - "the command's name and the words that make it this command", m.Module, tool, c, MaxReplaced)) - case seen[c]: - problems = append(problems, fmt.Sprintf("%s: %s replaces %q twice", m.Module, tool, c)) - } - seen[c] = true - } - } - return problems -} - -// invokeProblems judges what a module says it calls (novox/hq ADR 0152). -// -// Refused here, in the manifest's words, rather than at the next composition of the bus's user -// list — where a bad entry would stop the whole file being written for everybody, as a person's -// malformed grant would have (operator.go). An entry that names a module and no tool is the one -// mistake worth naming: `shop` reads like a grant to a module's tools and would be a grant to nothing. -func invokeProblems(m Manifest) []string { - var problems []string - for _, t := range m.Invokes { - if !invokeNameValid(t) { - problems = append(problems, fmt.Sprintf( - "%s invokes %q, which does not name a tool: a module invokes ., or "+ - "* for every tool on the mesh (novox/hq ADR 0152)", m.Module, t)) - } - } - return problems -} - -// waitToken is one name of an event family's subject: never a wildcard, never a dot, never empty. -var waitToken = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) - -// waitProblems judges the event families a module says a session may wait on (novox/hq ADR 0367 §2, §6): -// the tool runner's alone, each named whole — emitter and event, with no wildcard — and each once. Refused -// here, in the manifest's words, rather than at the composition of the bus's users. -func waitProblems(m Manifest) []string { - if len(m.Waits) == 0 { - return nil - } - if m.Module != RuntimeModule { - return []string{fmt.Sprintf("%s says what a session waits on; only %s, the tool runner's module, "+ - "declares waits (novox/hq ADR 0367 §2)", m.Module, RuntimeModule)} - } - var problems []string - seen := map[string]bool{} - for _, w := range m.Waits { - if strings.ContainsAny(w, "*>") { - problems = append(problems, fmt.Sprintf("%s waits on %q: a family is named one by one, never a "+ - "wildcard (novox/hq ADR 0367 §2)", m.Module, w)) - continue - } - tokens := strings.Split(w, ".") - whole := len(tokens) >= 2 && name.MatchString(tokens[0]) - for _, t := range tokens[1:] { - whole = whole && waitToken.MatchString(t) - } - if !whole { - problems = append(problems, fmt.Sprintf("%s waits on %q, which does not name an emitter and an "+ - "event: a family is ., as consumes names one", m.Module, w)) - continue - } - if seen[w] { - problems = append(problems, fmt.Sprintf("%s waits on %q twice", m.Module, w)) - } - seen[w] = true - } - return problems -} - // How a module takes one of its own secrets (ADR 0114): read from the file when it starts, or // applied by its own code to a backend that takes it once. const ( @@ -3103,74 +1959,6 @@ func (s OwnSecret) MeshMayMake() bool { // desk's prompt reads at a glance. const PromptMax = 120 -// promptPlaceholder is a `{word}` token in a prompt's line; the only one a prompt may hold is `{member}`, a -// family's member suffix (novox/hq issue 789). -var promptPlaceholder = regexp.MustCompile(`\{[^{}]*\}`) - -// ownSecretPromptProblems is what is wrong with an own secret's declared prompt, in the module check's words: -// empty is fine (no prompt is declared, and the desk asks by name alone as it does today); otherwise one line, -// at most PromptMax characters — counting what a person reads, not its bytes — holding only the `{member}` -// placeholder, and only where the secret is a family: a single secret's prompt has no member to fill, so -// `{member}` there would stay in the prompt literally (novox/hq issue 789). -func ownSecretPromptProblems(m Manifest, name, prompt string, family bool) []string { - if prompt == "" { - return nil - } - var problems []string - if strings.ContainsAny(prompt, "\n\r") { - problems = append(problems, fmt.Sprintf( - "%s names its secret %q with a prompt that holds more than one line; a prompt is one line (novox/hq issue 789)", - m.Module, name)) - } - if n := utf8.RuneCountInString(prompt); n > PromptMax { - problems = append(problems, fmt.Sprintf( - "%s names its secret %q with a prompt %d characters long; a prompt is at most %d characters", - m.Module, name, n, PromptMax)) - } - for _, ph := range promptPlaceholder.FindAllString(prompt, -1) { - if ph != "{member}" { - problems = append(problems, fmt.Sprintf( - "%s names its secret %q with a prompt that holds %s; a prompt holds only the {member} placeholder, "+ - "filled with a family's member suffix (novox/hq issue 789)", - m.Module, name, ph)) - } else if !family { - problems = append(problems, fmt.Sprintf( - "%s names its secret %q with a prompt that holds {member}; {member} is replaced only for a "+ - "family's member (novox/hq issue 789)", - m.Module, name)) - } - } - return problems -} - -// ownSecretOwnerProblems is what is wrong with an own secret's declared owner (novox/hq ADR 0436 §1): naming -// an account the module makes — a `user` resource of that name — never root, which a secret file owned by it -// would make readable by anything, and never the operator account's placeholder (`${machine:account}`), which -// the mesh fills per machine and a manifest cannot say safely (novox/hq ADR 0436, settled by the lead). -func ownSecretOwnerProblems(m Manifest, name, owner string) []string { - if owner == "" { - return nil - } - if owner == "root" { - return []string{fmt.Sprintf( - "%s names %q as the owner of its secret %q; an own secret's owner is an account the module "+ - "makes, never root (novox/hq ADR 0436 §1)", m.Module, owner, name)} - } - if owner == "${machine:account}" { - return []string{fmt.Sprintf( - "%s names %q as the owner of its secret %q; an own secret's owner is an account the module "+ - "makes, never the operator account (novox/hq ADR 0436 §1)", m.Module, owner, name)} - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == owner { - return nil - } - } - return []string{fmt.Sprintf( - "%s names %q as the owner of its secret %q, which it does not make: a user resource named %q "+ - "(novox/hq ADR 0436 §1)", m.Module, owner, name, owner)} -} - // OwnSecrets is a module's own secrets by name. On the wire each is a path, or an object naming // the path and how it is taken; written back the way it was read, so a manifest the mesh holds // keeps its bytes. @@ -3311,85 +2099,3 @@ const InstancesInterchangeable = "interchangeable" // WhileStopped is the resource key naming the containers a scheduled step holds still while it // runs (novox/hq ADR 0189). Carried to the host unchanged, like `schedule`. const WhileStopped = "while-stopped" - -// hasSchedule is whether a resource declares a cadence, as a string. -func hasSchedule(r map[string]any) bool { - s, _ := r["schedule"].(string) - return s != "" -} - -// whileStoppedProblems judges one container's maintenance window against its own definition -// (novox/hq ADR 0189). -// -// Three things the manifest is the only place to see: that the step is scheduled (a one-time -// offline job says *before* rather than *instead of* — at apply the host already has a window, -// because the declaration is applied in order and a run-once step gates what follows); that every -// id it names is **this module's own** container; and that it does not name itself. -// -// The host checks the fourth — that the container is actually placed on that machine — because -// that is a fact about the declaration and not about the definition. -func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string { - raw, present := r[WhileStopped] - if !present { - return nil - } - ids, ok := raw.([]any) - if !ok { - return []string{fmt.Sprintf( - "%s declares %s on %v as a %T; it is a list of this module's container ids", - m.Module, WhileStopped, r["id"], raw)} - } - var problems []string - if len(ids) > 0 && !scheduled { - problems = append(problems, fmt.Sprintf( - "%s declares %s on %v, which has no schedule. A maintenance window is for a recurring "+ - "step: at apply the mesh already has one, because a run-once step gates what is "+ - "declared after it (novox/hq ADR 0189)", m.Module, WhileStopped, r["id"])) - } - containers := map[string]bool{} - for _, own := range m.Resources { - if fmt.Sprint(own["type"]) == "container" { - containers[fmt.Sprint(own["id"])] = true - } - } - for _, each := range ids { - id, ok := each.(string) - if !ok { - problems = append(problems, fmt.Sprintf( - "%s declares %s on %v naming a %T; each entry is a container's id", - m.Module, WhileStopped, r["id"], each)) - continue - } - if id == fmt.Sprint(r["id"]) { - problems = append(problems, fmt.Sprintf( - "%s declares %s on %v naming itself", m.Module, WhileStopped, r["id"])) - continue - } - if !containers[id] { - problems = append(problems, fmt.Sprintf( - "%s declares %s on %v naming %q, which is not a container this module declares. "+ - "A step may hold still its own module's containers and nobody else's — one "+ - "that could quiesce a neighbour could stop the mesh", - m.Module, WhileStopped, r["id"], id)) - } - } - return problems -} - -// shipsABundle says the module builds a tools bundle, which the node's runtime carries and speaks for. -func (m Manifest) shipsABundle() bool { - // A built manifest says its bundles in Bundles, its build read away (Resolve; issue 774: nats's built manifest was - // refused for the emits its tools bundle says). - if len(m.Bundles) > 0 { - return true - } - if m.Build == nil { - return false - } - for _, a := range m.Build.Artifacts { - if a.Kind == ArtifactBundle { - return true - } - } - return false -} diff --git a/go/catalogue/mounts_test.go b/go/catalogue/mounts_test.go index 1e0a3df..ded7c18 100644 --- a/go/catalogue/mounts_test.go +++ b/go/catalogue/mounts_test.go @@ -22,20 +22,9 @@ func manifestMounting(from string, beside string) []byte { return []byte(`{"module":"store","resources":[` + strings.Replace(res, "%s", from, 1) + `]}`) } -func TestAMountNothingDeclaresIsRefused(t *testing.T) { - _, err := ParseManifest(manifestMounting("/services/store/data", "")) - if err == nil { - t.Fatal("a container mounting a path no resource declares was accepted; the runtime " + - "would create it as root and the module's owner and mode would never apply") - } - if !strings.Contains(err.Error(), "/services/store/data") { - t.Fatalf("refused without naming the path, which leaves the author guessing: %v", err) - } -} - // Declaring it is enough — including declaring the directory above it. func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) { - _, err := ParseManifest(manifestMounting("/services/store/data", + _, err := decodeForTest(manifestMounting("/services/store/data", `{"id":"state","type":"directory","path":"/services/store","mode":"0700"}`)) if err != nil { t.Fatalf("a module that said where its data lives was refused anyway: %v", err) @@ -45,27 +34,13 @@ func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) { // The operator's path, granted for use (ADR 0051), is declared by `accesses`, not by a directory the // module would then own. func TestAMountOfAnAccessedPathIsAccepted(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"store","accesses":[{"path":"/services/media/movies","mode":"read"}],` + + _, err := decodeForTest([]byte(`{"module":"store","accesses":[{"path":"/services/media/movies","mode":"read"}],` + `"resources":[` + strings.Replace(aContainerMounting, "%s", "/services/media/movies", 1) + `]}`)) if err != nil { t.Fatalf("a mount of a path the module declares it accesses was refused: %v", err) } } -// The machine's facility — the container runtime's socket — is granted by the capability that names -// it, and declaring it as the module's own directory would be a lie the host would act on. This is -// the case the first version of this check refused, and was withdrawn for: the builder. -func TestTheRuntimeSocketIsGrantedByTheCapabilityAndNotOtherwise(t *testing.T) { - granted := `{"module":"builder","capabilities":["container-runtime"],"resources":[` + - strings.Replace(aContainerMounting, "%s", "/var/run/docker.sock", 1) + `]}` - if _, err := ParseManifest([]byte(granted)); err != nil { - t.Fatalf("a module with the container-runtime capability may mount its socket: %v", err) - } - if _, err := ParseManifest(manifestMounting("/var/run/docker.sock", "")); err == nil { - t.Fatal("a module mounted the container runtime's socket without declaring the capability, and was accepted") - } -} - // **Every manifest in the catalogue (internal/beside) passes**, so the rule is not one the catalogue // is already breaking. func TestEveryCatalogueManifestDeclaresWhatItMounts(t *testing.T) { @@ -79,7 +54,7 @@ func TestEveryCatalogueManifestDeclaresWhatItMounts(t *testing.T) { // Where a bound fact lands is the mesh's file too, and a container may mount it directly. func TestAMountOfABoundFactIsAccepted(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"store","requires":["model-access"],` + + _, err := decodeForTest([]byte(`{"module":"store","requires":["model-access"],` + `"binds":{"model-access":"/var/lib/store/model.json"},"resources":[` + strings.Replace(aContainerMounting, "%s", "/var/lib/store/model.json", 1) + `]}`)) if err != nil { @@ -90,7 +65,7 @@ func TestAMountOfABoundFactIsAccepted(t *testing.T) { // The file a filter module's rule set is written to is declared by `filtering.into` (novox/hq ADR // 0169): the module's runtime mounts it to reload the mesh's own table, and nothing else declares it. func TestAMountOfTheFilterFileIsDeclaredByFilteringInto(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` + + _, err := decodeForTest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` + `"resources":[` + strings.Replace(aContainerMounting, "%s", "/etc/nftables.conf", 1) + `]}`)) if err != nil { t.Fatalf("a filter module mounting its own filter file was refused: %v", err) diff --git a/go/catalogue/naming.go b/go/catalogue/naming.go index 7198cd8..1b52013 100644 --- a/go/catalogue/naming.go +++ b/go/catalogue/naming.go @@ -1,17 +1 @@ package catalogue - -// Provision names that say a role where the consumer is coupled to an implementation. -// -// **A consumer's code is written against PostgreSQL or against SQL Server, never against a -// database** (novox/hq ADR 0027). A requirement naming the role matches any engine, resolves as -// satisfied, deploys, and fails on the first query — with nothing connecting the error back to a -// match made elsewhere by something that thought it had done its job. -// -// Refused rather than documented, because the previous naming *was* the documented convention. -// A rule states how it is checked (novox/hq 00-META/how-we-build.md §5). -var engineGeneric = map[string]string{ - "database": "postgres-database, mssql-database, mariadb-database", - "db": "postgres-database, mssql-database, mariadb-database", - "sql": "postgres-database, mssql-database, mariadb-database", - "sql-database": "postgres-database, mssql-database, mariadb-database", -} diff --git a/go/catalogue/naming_test.go b/go/catalogue/naming_test.go index c168ea4..b455f31 100644 --- a/go/catalogue/naming_test.go +++ b/go/catalogue/naming_test.go @@ -1,36 +1,14 @@ package catalogue import ( - "strings" "testing" ) -// A provision names what the consumer is coupled to (novox/hq ADR 0027). -// -// The fault this defends against does not look like a fault: resolution succeeds, the module -// deploys, and the first query fails somewhere else entirely. Every earlier test had exactly one -// provider of each name, so no mismatch was expressible and none was caught. -func TestAModuleMayNotProvideAGenericDatabase(t *testing.T) { - for _, hidden := range []string{"database", "db", "sql", "sql-database"} { - _, err := ParseManifest([]byte(`{"module":"postgres","version":"1", - "provides":[{"name":"` + hidden + `","scope":"mesh"}]}`)) - if err == nil { - t.Fatalf("providing %q was accepted; a requirement for it matches any engine", hidden) - } - for _, want := range []string{hidden, "postgres-database", "first query"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("refusing %q did not mention %q, so nobody learns what to write:\n%v", - hidden, want, err) - } - } - } -} - // The rule is about coupling, not about specificity everywhere. Naming `route` after a particular // proxy would be the same error in the other direction. func TestARoleNameIsFineWhereTheConsumerCannotTellTheDifference(t *testing.T) { for _, role := range []string{"route", "resolver", "artifact-store", "postgres-database"} { - if _, err := ParseManifest([]byte(`{"module":"m","version":"1", + if _, err := decodeForTest([]byte(`{"module":"m","version":"1", "provides":[{"name":"` + role + `","scope":"mesh"}]}`)); err != nil { t.Errorf("providing %q was refused, and it names what a consumer actually gets: %v", role, err) @@ -38,32 +16,9 @@ func TestARoleNameIsFineWhereTheConsumerCannotTellTheDifference(t *testing.T) { } } -// A module may not declare an action, and it is refused where it was written. -// -// The host refuses one arriving over the link, which is the bound the whole security argument -// rests on (novox/hq ADR 0005) and is enforced in the right place. But a module's resources reach -// a machine *over* the link, so a manifest carrying an action used to be accepted here, stored, -// resolved and pushed — and then refused on the machine, in a log, with nothing tying it back to -// the manifest. Enforced at the far end only is enforced and not usable. -func TestAModuleMayNotDeclareAnAction(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[ - {"id":"migrate","type":"action","command":["/bin/true"],"verify":["/bin/true"]}]}`)) - if err == nil { - t.Fatal("a manifest declaring an action was accepted, and the machine would refuse it") - } - if !strings.Contains(err.Error(), "may not") || !strings.Contains(err.Error(), "0005") { - t.Errorf("the refusal does not say what rule it is: %v", err) - } - // And it says what to do instead, because "you may not" without an alternative is where a - // module author stops. - if !strings.Contains(err.Error(), "ships a program") { - t.Errorf("the refusal names no alternative: %v", err) - } -} - // Every other shape a module may declare still passes, so the check above bounds one thing. func TestTheOtherShapesAreStillAccepted(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[ + _, err := decodeForTest([]byte(`{"module":"probe","version":"1","resources":[ {"id":"d","type":"directory","path":"/var/lib/probe","mode":"0700"}, {"id":"f","type":"file","path":"/var/lib/probe/x","content":"x\n"}, {"id":"n","type":"network","name":"probe"}, diff --git a/go/catalogue/needs_test.go b/go/catalogue/needs_test.go index 8a176a1..3a00dea 100644 --- a/go/catalogue/needs_test.go +++ b/go/catalogue/needs_test.go @@ -60,17 +60,6 @@ func TestADeclaredNeedThatWasNotMadeIsRefused(t *testing.T) { } } -func TestANeedIsAnAbsolutePath(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"postgres","version":"1", - "own-secrets":{"superuser":"superuser.txt"}}`)) - if err == nil { - t.Fatal("a relative path was accepted") - } - if !strings.Contains(err.Error(), "absolute path") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - func TestAModuleMayNeedSeveralThings(t *testing.T) { // A password and a token, say. Telling them apart is the module's business, not the mesh's. m := needy() @@ -92,33 +81,3 @@ func TestAModuleMayNeedSeveralThings(t *testing.T) { t.Fatalf("two needs did not land as two secrets: %v", seen) } } - -// A manifest written against the old name is told what the field became. -// -// `needs` and `secrets` were both name-to-path and differed only in whose secret it was, so -// reaching for the wrong one parsed cleanly and failed somewhere else entirely. Refusing the old -// name with "unknown field" would be correct and unhelpful: whoever wrote it knew what they meant, -// and the mesh knows what it is called now. -func TestAManifestUsingTheOldNameIsToldTheNewOne(t *testing.T) { - _, err := ParseManifest([]byte( - `{"module":"postgres","version":"1","needs":{"superuser":"/var/lib/superuser"}}`)) - if err == nil { - t.Fatal("a manifest using the old name was accepted, so two fields now mean one thing") - } - for _, want := range []string{"needs", "own-secrets"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("the refusal does not mention %q: %v", want, err) - } - } -} - -// And an ordinary unknown key is still refused as one, rather than being guessed at. -func TestAnInventedKeyIsNotTreatedAsARename(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"postgres","version":"1","nonsense":{}}`)) - if err == nil { - t.Fatal("an invented key was accepted") - } - if strings.Contains(err.Error(), "is now called") { - t.Fatalf("an invented key was reported as a rename: %v", err) - } -} diff --git a/go/catalogue/no_subjects_test.go b/go/catalogue/no_subjects_test.go index a247296..2d493a1 100644 --- a/go/catalogue/no_subjects_test.go +++ b/go/catalogue/no_subjects_test.go @@ -74,25 +74,6 @@ func TestNoManifestContainsASubject(t *testing.T) { t.Logf("%d manifests hold no subject", checked) } -// **Every module's event names are what design 29 says, across the whole catalogue.** -// -// The rule above holds by construction and turned out to be weaker than it reads: a manifest holds -// no subject, and every manifest in the catalogue still held the old bus's routing key, which -// derives into a namespace nobody owns (novox/hq 04-ISSUES/127). Nothing failed — the services -// started and none of them reacted. This is the check that was missing. -func TestEveryManifestsEventNamesAreLocal(t *testing.T) { - manifests := theCatalogue(t) - - var problems []string - for _, m := range manifests { - problems = append(problems, EventProblems(m)...) - } - if len(problems) > 0 { - t.Fatalf("the catalogue holds %d event name(s) the mesh would derive wrongly:\n %s", - len(problems), strings.Join(problems, "\n ")) - } -} - // theCatalogue is every manifest of the catalogue internal/beside finds, parsed the way registration // parses one. func theCatalogue(t *testing.T) []Manifest { diff --git a/go/catalogue/optional_requirements.go b/go/catalogue/optional_requirements.go index 921b1d7..ab40521 100644 --- a/go/catalogue/optional_requirements.go +++ b/go/catalogue/optional_requirements.go @@ -193,102 +193,3 @@ func withoutAbsent(resources []map[string]any, absent map[string]bool) []map[str } return out } - -// secretNamesOf is every `${secret:…}` name that carries a credential of this requirement: its own -// name, and each local name it keeps several under (ADR 0094). -func secretNamesOf(m Manifest, requirement string) []string { - names := []string{requirement} - for local := range m.SecretsMany[requirement] { - names = append(names, local) - } - sort.Strings(names) - return names -} - -// OptionalProblems is what the module check refuses of a manifest's optional requirements and its -// only-when-bound resources (ADR 0330 §4–§6). -func OptionalProblems(m Manifest) []string { - var problems []string - required := map[string]bool{} - for _, r := range m.Requires { - if required[r] && m.IsOptional(r) { - problems = append(problems, fmt.Sprintf("%s requires %q twice; say it once, optional or not", m.Module, r)) - } - required[r] = true - } - for _, r := range optionalRequiresList(m) { - if !required[r] { - problems = append(problems, fmt.Sprintf( - "%s says %q is optional in %s and does not require it", m.Module, r, OptionalRequiresKey)) - } - } - // A contribution is to whoever answers the requirement, so it goes with it: contributing to one - // that is optional is allowed, and composed only while it is bound. - for _, r := range m.Resources { - id := fmt.Sprint(r["id"]) - when, marked := r[OnlyWhenBound] - on := "" - if marked { - name, ok := when.(string) - switch { - case !ok || name == "": - problems = append(problems, fmt.Sprintf( - "%s's resource %s says %s %v; it names one requirement the module requires optionally", - m.Module, id, OnlyWhenBound, when)) - continue - case fmt.Sprint(r["type"]) != "file": - problems = append(problems, fmt.Sprintf( - "%s's resource %s is a %v and says %s: only a file is left out while a requirement is "+ - "absent (novox/hq ADR 0330 §5)", m.Module, id, r["type"], OnlyWhenBound)) - continue - case !required[name]: - problems = append(problems, fmt.Sprintf( - "%s's resource %s is delivered only when %q is bound, and %s does not require it", - m.Module, id, name, m.Module)) - continue - case !m.IsOptional(name): - problems = append(problems, fmt.Sprintf( - "%s's resource %s is delivered only when %q is bound, which is a hard requirement and "+ - "always bound: mark the requirement optional, or drop %s", m.Module, id, name, OnlyWhenBound)) - continue - } - on = name - } - if fmt.Sprint(r["type"]) != "file" { - continue - } - content, _ := r["content"].(string) - for _, use := range bound.FindAllStringSubmatch(content, -1) { - provision, key, hasDefault := use[1], use[2], use[3] != "" - if !m.IsOptional(provision) { - if hasDefault { - problems = append(problems, fmt.Sprintf( - "%s's resource %s gives ${bound:%s:%s} a default, and %q is a hard requirement, always "+ - "bound: a default it never renders only misleads a reader", m.Module, id, provision, key, provision)) - } - continue - } - if !hasDefault && on != provision { - problems = append(problems, fmt.Sprintf( - "%s's resource %s says ${bound:%s:%s}, and %q is optional: while it is absent this would "+ - "be empty. Name its value then — ${bound:%s:%s|default:} — or deliver the file "+ - "only while it is bound, with \"%s\": %q (novox/hq ADR 0330 §4)", - m.Module, id, provision, key, provision, provision, key, OnlyWhenBound, provision)) - } - } - for _, provision := range m.Requires { - if !m.IsOptional(provision) || on == provision { - continue - } - for _, name := range secretNamesOf(m, provision) { - if strings.Contains(content, "${secret:"+name+"}") { - problems = append(problems, fmt.Sprintf( - "%s's resource %s says ${secret:%s}, the credential of %q, which is optional and not "+ - "delivered while absent: deliver the file only while it is bound, with \"%s\": %q "+ - "(novox/hq ADR 0330 §6)", m.Module, id, name, provision, OnlyWhenBound, provision)) - } - } - } - } - return problems -} diff --git a/go/catalogue/optional_requirements_test.go b/go/catalogue/optional_requirements_test.go index 0b6f808..2e92003 100644 --- a/go/catalogue/optional_requirements_test.go +++ b/go/catalogue/optional_requirements_test.go @@ -54,60 +54,12 @@ func TestARequirementIsHardUnlessMarkedOptional(t *testing.T) { } } -// The stored form reads on a controller older than optional requirements (review of mesh-controller -// #281). Inventory.Catalogue stops at the first manifest it cannot read, so a long-form entry inside -// `requires` would have stopped every plan after a rollback. Stored, `requires` is plain names, as main's -// manifest type reads it, and which are optional sits beside it in a key such a controller sets aside. -func TestTheStoredFormReadsWhereRequiresIsAListOfNames(t *testing.T) { - m := mustParse(t, `{"module":"grafana","version":"1", - "requires":["route",{"name":"influxdb-api","optional":true}]}`) - raw, err := json.Marshal(m) - if err != nil { - t.Fatal(err) - } - // main's field, exactly: `Requires []string`. - var older struct { - Requires []string `json:"requires"` - } - if err := json.Unmarshal(raw, &older); err != nil { - t.Fatalf("a controller reading requires as names cannot read the stored form: %v\n%s", err, raw) - } - if strings.Join(older.Requires, " ") != "route influxdb-api" { - t.Fatalf("it reads the requirements as %v", older.Requires) - } - var keys map[string]json.RawMessage - if err := json.Unmarshal(raw, &keys); err != nil { - t.Fatal(err) - } - if string(keys[OptionalRequiresKey]) != `["influxdb-api"]` { - t.Fatalf("which are optional is stored as %s", keys[OptionalRequiresKey]) - } - // And this controller reads the stored form back the same, leniently and strictly. - var back Manifest - if err := json.Unmarshal(raw, &back); err != nil { - t.Fatal(err) - } - strict, err := ParseManifest(raw) - if err != nil { - t.Fatal(err) - } - for _, got := range []Manifest{back, strict} { - if !got.IsOptional("influxdb-api") || got.IsOptional("route") || len(got.Requires) != 2 { - t.Fatalf("read back as %v optional %v", got.Requires, got.OptionalRequires) - } - } - // A stored list naming something the module does not require is refused. - if _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["route"],"optional-requires":["mqtt-topic"]}`)); err == nil { - t.Error("an optional requirement the module does not require passed the check") - } -} - func TestARequirementsLongFormRefusesWhatItDoesNotKnow(t *testing.T) { for _, raw := range []string{ `{"module":"x","version":"1","requires":[{"name":"influxdb-api","optinal":true}]}`, `{"module":"x","version":"1","requires":[{"optional":true}]}`, } { - if _, err := ParseManifest([]byte(raw)); err == nil { + if _, err := decodeForTest([]byte(raw)); err == nil { t.Errorf("accepted %s", raw) } } @@ -202,42 +154,6 @@ func optionalModule(resources string) string { "resources":[` + resources + `]}` } -func TestTheModuleCheckRefusesAnOptionalPlaceholderThatCouldRenderEmpty(t *testing.T) { - refused := map[string]string{ - "a placeholder without a default": `{"id":"ds","type":"file","path":"/a","mode":"0644", - "content":"url: ${bound:influxdb-api:at}"}`, - "a secret outside an only-when-bound file": `{"id":"s","type":"file","path":"/a","mode":"0400", - "content":"${secret:influxdb-api}"}`, - "only-when-bound on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644", - "content":"x","only-when-bound":"route"}`, - "only-when-bound on something not required": `{"id":"r","type":"file","path":"/a","mode":"0644", - "content":"x","only-when-bound":"mqtt-topic"}`, - "only-when-bound on a directory": `{"id":"d","type":"directory","mode":"0700", - "only-when-bound":"influxdb-api"}`, - "a default on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644", - "content":"${bound:route:name|default:x}"}`, - } - for what, resource := range refused { - if _, err := ParseManifest([]byte(optionalModule(resource))); err == nil { - t.Errorf("%s passed the module check", what) - } else if what == "a placeholder without a default" && !strings.Contains(err.Error(), "|default:") { - t.Errorf("the refusal does not say how to write it: %v", err) - } - } - accepted := map[string]string{ - "a placeholder with a default": `{"id":"ds","type":"file","path":"/a","mode":"0644", - "content":"url: ${bound:influxdb-api:at|default:none}"}`, - "a placeholder and a secret inside an only-when-bound file": `{"id":"ds","type":"file","path":"/a", - "mode":"0400","content":"url: ${bound:influxdb-api:at}\npassword: ${secret:influxdb-api}", - "only-when-bound":"influxdb-api"}`, - } - for what, resource := range accepted { - if _, err := ParseManifest([]byte(optionalModule(resource))); err != nil { - t.Errorf("%s was refused: %v", what, err) - } - } -} - func absentGrafana(t *testing.T, absent bool) Resolution { t.Helper() m := mustParse(t, optionalModule(` diff --git a/go/catalogue/outside_not_given_test.go b/go/catalogue/outside_not_given_test.go index a63e3d8..2c5ead0 100644 --- a/go/catalogue/outside_not_given_test.go +++ b/go/catalogue/outside_not_given_test.go @@ -11,7 +11,7 @@ import ( func outsideManifest(t *testing.T) Manifest { t.Helper() - m, err := ParseManifest([]byte(`{"module":"assistant","version":"1", + m, err := decodeForTest([]byte(`{"module":"assistant","version":"1", "own-secrets":{ "password":{"path":"/var/lib/assistant/password.secret","taken":"at-start"}, "vendor-key":{"path":"/var/lib/assistant/vendor-key.secret","taken":"at-start","issued-by":"outside"}}, diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index f1f7196..6f669b6 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -14,7 +14,7 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { if err != nil { t.Fatal(err) } - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { t.Fatalf("mesh-sdk's module.json does not parse: %v", err) } diff --git a/go/catalogue/own_secret_owner_test.go b/go/catalogue/own_secret_owner_test.go index fba354a..57a948c 100644 --- a/go/catalogue/own_secret_owner_test.go +++ b/go/catalogue/own_secret_owner_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -20,7 +19,7 @@ func mailSenderManifest(t *testing.T, own string) Manifest { ownSecrets += `,"owner":"` + own + `"` } ownSecrets += `}` - m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","secrets-owner":"mesh-mail","own-secrets":{` + + m, err := decodeForTest([]byte(`{"module":"mesh-mail","version":"1","secrets-owner":"mesh-mail","own-secrets":{` + ownSecrets + `},"resources":[{"id":"account","type":"user","name":"mesh-mail-send"}]}`)) if err != nil { t.Fatalf("a manifest with own=%q was refused: %v", own, err) @@ -73,23 +72,6 @@ func TestAnOwnSecretsOwnerIsGivenToItsFiles(t *testing.T) { } } -// 1170/AC2: an owner naming an account the module does not make, root, or the operator account's placeholder is -// refused, naming the secret and the account. -func TestAnOwnSecretsOwnerIsRefusedWhenItIsNotAnAccountTheModuleMakes(t *testing.T) { - for name, c := range map[string]struct{ owner, says string }{ - "root": {"root", "root"}, - "the operator account": {"${machine:account}", "operator account"}, - "an account not declared": {"not-made", "not-made"}, - } { - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{` + - `"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside","owner":"` + - c.owner + `"}},"resources":[{"id":"account","type":"user","name":"mesh-mail-send"}]}`)) - if err == nil || !strings.Contains(err.Error(), "mail-credential-*") || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming the secret and %q", name, err, c.says) - } - } -} - // 1170/AC3: a manifest with no owner on any own secret composes exactly as it did before this clause existed: // every secret file belongs to the module's secrets-owner (or nothing, when that is absent too). func TestNoOwnerOnAnySecretComposesAsMainDoes(t *testing.T) { diff --git a/go/catalogue/own_secret_prompt_test.go b/go/catalogue/own_secret_prompt_test.go index f0d6575..ef278e8 100644 --- a/go/catalogue/own_secret_prompt_test.go +++ b/go/catalogue/own_secret_prompt_test.go @@ -10,7 +10,7 @@ import ( // its bare name (novox/hq issue 789): reviewed in the module's pull request, and the controller validates it in // the module check. func TestAnOwnSecretsPromptParsesAndIsWrittenBack(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + m, err := decodeForTest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", "prompt":"the password of the mail account {member}"}, "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", @@ -41,7 +41,7 @@ func TestAnOwnSecretsPromptParsesAndIsWrittenBack(t *testing.T) { // No prompt declared is fine: the desk asks by name alone, as it does today. func TestAnOwnSecretWithNoPromptIsFine(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"telegram","version":"1","own-secrets":{ + m, err := decodeForTest([]byte(`{"module":"telegram","version":"1","own-secrets":{ "telegram-token":{"path":"/var/lib/telegram/telegram-token","issued-by":"outside"}}}`)) if err != nil { t.Fatal(err) @@ -65,7 +65,7 @@ func TestAPromptOfMultiByteCharactersIsCountedByCharacter(t *testing.T) { if n := len(prompt); n <= 120 { t.Fatalf("the test prompt is %d bytes, not over 120 as intended", n) } - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + _, err := decodeForTest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", "prompt":` + jsonString(prompt) + `}}}`)) if err != nil { @@ -73,34 +73,6 @@ func TestAPromptOfMultiByteCharactersIsCountedByCharacter(t *testing.T) { } } -// {member} is replaced only for a family's member: a single secret's prompt may not hold it (novox/hq issue 789). -func TestAPromptHoldingMemberOnASingleSecretIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ - "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", - "prompt":"For {member}"}}}`)) - if err == nil || !strings.Contains(err.Error(), "{member} is replaced only for a family's member") { - t.Fatalf("a single secret's prompt holding {member}: %v; want a refusal saying so", err) - } -} - -// A prompt that is not one short line naming only {member} is refused by the module check (novox/hq issue 789). -func TestAMalformedPromptIsRefused(t *testing.T) { - for name, c := range map[string]struct{ prompt, says string }{ - "a newline": {"the password of\nthe mail account {member}", "one line"}, - "over 120 characters": {"the password of the mail account {member}, which this mesh needs to log in to " + - "send and receive mail for the operator's account on this machine today", "120 characters"}, - "a placeholder other than {member}": {"the password of the mail account {name}", "{member}"}, - "two placeholders, one unknown": {"the {kind} secret of the mail account {member}", "{member}"}, - } { - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ - "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", - "prompt":` + jsonString(c.prompt) + `}}}`)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says) - } - } -} - // jsonString quotes a Go string the way encoding/json would, for building a literal into a test's manifest JSON. func jsonString(s string) string { out, err := json.Marshal(s) diff --git a/go/catalogue/own_secret_taken_test.go b/go/catalogue/own_secret_taken_test.go index 4ad016c..d6d28c0 100644 --- a/go/catalogue/own_secret_taken_test.go +++ b/go/catalogue/own_secret_taken_test.go @@ -10,7 +10,7 @@ import ( // nothing of it, an object says `at-start` or `applied`, and the bytes the mesh holds are the bytes // it was given either way. func TestAnOwnSecretSaysHowItIsTaken(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ + m, err := decodeForTest([]byte(`{"module":"idp","version":"1","own-secrets":{ "broker":"/var/lib/mesh/idp/broker", "admin":{"path":"/var/lib/idp/admin.secret","taken":"applied"}, "session":{"path":"/var/lib/idp/session.secret","taken":"at-start"}}}`)) @@ -39,55 +39,3 @@ func TestAnOwnSecretSaysHowItIsTaken(t *testing.T) { t.Fatalf("a path alone is written back as a path: %s", out) } } - -func TestAnOwnSecretTakenSomeOtherWayIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ - "admin":{"path":"/var/lib/idp/admin.secret","taken":"sometimes"}}}`)) - if err == nil || !strings.Contains(err.Error(), `taken "sometimes"`) { - t.Fatalf("an unknown word for how a secret is taken was accepted: %v", err) - } - _, err = ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ - "admin":{"path":"/var/lib/idp/admin.secret","rotate":"yes"}}}`)) - if err == nil { - t.Fatal("an unknown field on an own secret was accepted") - } -} - -// A secret an outside party issues says so (novox/hq ADR 0228), is written back as it was read, and -// is the one at-start secret the mesh may not make; any other word for who issued it is refused. -func TestAnOwnSecretSaysWhenAnOutsidePartyIssuesIt(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ - "server-password":{"path":"/var/lib/letta/server-password","taken":"at-start"}, - "openai-api-key":{"path":"/var/lib/letta/openai-api-key","taken":"at-start","issued-by":"outside"}, - "telegram-token":{"path":"/var/lib/letta/telegram-token","issued-by":"outside"}, - "logflare":{"path":"/var/lib/letta/logflare","taken":"applied"}, - "broker":"/var/lib/mesh/letta/broker"}}`)) - if err != nil { - t.Fatal(err) - } - for name, may := range map[string]bool{"server-password": true, "openai-api-key": false, - "telegram-token": false, "logflare": false, "broker": false} { - if got := m.OwnSecrets[name].MeshMayMake(); got != may { - t.Errorf("%s: the mesh may make it = %v, want %v", name, got, may) - } - } - out, err := json.Marshal(m.OwnSecrets) - if err != nil { - t.Fatal(err) - } - var again OwnSecrets - if err := json.Unmarshal(out, &again); err != nil { - t.Fatal(err) - } - if again["openai-api-key"] != m.OwnSecrets["openai-api-key"] || again["telegram-token"] != m.OwnSecrets["telegram-token"] { - t.Fatalf("the round trip lost who issued it: %s", out) - } - if strings.Contains(string(out), `"taken":""`) { - t.Fatalf("a secret that says only who issued it gained an empty taken: %s", out) - } - _, err = ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ - "openai-api-key":{"path":"/var/lib/letta/openai-api-key","issued-by":"openai"}}}`)) - if err == nil || !strings.Contains(err.Error(), `issued by "openai"`) { - t.Fatalf("an unknown word for who issued a secret was accepted: %v", err) - } -} diff --git a/go/catalogue/paths_test.go b/go/catalogue/paths_test.go index 33b9227..dee61d7 100644 --- a/go/catalogue/paths_test.go +++ b/go/catalogue/paths_test.go @@ -5,46 +5,6 @@ import ( "testing" ) -// An artifact names the paths it is built from with `paths` (novox/hq ADR 0499 clause 1): a package, or a bundle in a -// language other than Go, may; a Go program's artifact, an archive, an image and an upstream artifact may not, and the -// refusal names the artifact and its kind. Each path stays inside the module's directory: no `..`, no absolute path, -// no pattern. -func TestAnArtifactNamesThePathsItIsBuiltFrom(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk","build":{"artifacts":[ - {"name":"lib","kind":"package","language":"typescript", - "paths":["src","test","package.json","package-lock.json","tsconfig.json"]}]},"resources":[]}`)) - if err != nil { - t.Fatalf("a package naming its paths was refused: %v", err) - } - if got := strings.Join(m.Build.Artifacts[0].Paths, ","); got != "src,test,package.json,package-lock.json,tsconfig.json" { - t.Fatalf("the paths were not kept as written: %q", got) - } - if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"tools","kind":"bundle","language":"typescript","entrypoints":["index.js"],"loads":["index.js"],"paths":["src","package.json"]}]}}`)); err != nil { - t.Fatalf("a TypeScript bundle naming its paths was refused: %v", err) - } - - for _, c := range []struct{ artifact, says string }{ - {`{"name":"x","kind":"bundle","language":"go","system":"linux-amd64","paths":["cmd"]}`, `"x" is a Go program`}, - {`{"name":"x","kind":"archive","from":"files","paths":["files"]}`, `"x" is a "archive"`}, - {`{"name":"x","kind":"image","from":"Dockerfile","paths":["src"]}`, `"x" is a "image"`}, - {`{"name":"x","kind":"upstream","from":"nats:2.10","paths":["src"]}`, `"x" is a "upstream"`}, - {`{"name":"x","kind":"package","language":"typescript","paths":["../other"]}`, "leaves the module's directory"}, - {`{"name":"x","kind":"package","language":"typescript","paths":["src/../../x"]}`, "leaves the module's directory"}, - {`{"name":"x","kind":"package","language":"typescript","paths":["/etc"]}`, "leaves the module's directory"}, - {`{"name":"x","kind":"package","language":"typescript","paths":["src/*.ts"]}`, "is a pattern"}, - {`{"name":"x","kind":"package","language":"typescript","paths":["src/[ab]"]}`, "is a pattern"}, - {`{"name":"x","kind":"package","language":"typescript","paths":[""]}`, "names an empty path"}, - {`{"name":"x","kind":"package","language":"typescript","paths":["."]}`, "names the module's whole directory"}, - {`{"name":"x","kind":"package","language":"typescript","paths":[]}`, "names no path"}, - } { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says) - } - } -} - // The paths a module's tree is taken from are the union its artifacts name, cleaned and sorted, and only when every // artifact names some (novox/hq ADR 0499 clause 2); a module with an artifact that names none, or with none at all, // keeps its directory's tree. diff --git a/go/catalogue/placed_paths_test.go b/go/catalogue/placed_paths_test.go index 779c151..da32bf1 100644 --- a/go/catalogue/placed_paths_test.go +++ b/go/catalogue/placed_paths_test.go @@ -39,12 +39,12 @@ func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) { if string(old) == string(now) { continue } - m, err := ParseManifest(now) + m, err := decodeForTest(now) if err != nil { t.Errorf("%s: %v", module, err) continue } - earlier, err := ParseManifest(old) + earlier, err := decodeForTest(old) if err != nil { t.Errorf("%s before: %v", module, err) continue diff --git a/go/catalogue/placement.go b/go/catalogue/placement.go index a7c522a..c9f7137 100644 --- a/go/catalogue/placement.go +++ b/go/catalogue/placement.go @@ -169,105 +169,9 @@ func systemPath(path string) string { return "" } -// Where no directory or file a module's definition resolves to may be (novox/hq issue 496). -// -// mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to /docker: with the -// default root, /var/lib/docker, every container's filesystem. systemPath judged only the `places` and `accesses` -// settings, so the default layout and a definition's own paths reached the merge gate unjudged; it composed every -// machine and passed, and only the node-engine refused the directory, at apply, failing the walk. -// -// **Judged where a definition is judged, never where a machine is composed.** resolvedPathProblems runs in -// ParseManifest, which every route a definition takes into the mesh passes: `module check`, registration of a -// build (the builder's and the registry verbs'), and the merge gate's reading of the changed repository. A -// refusal there stops one definition before it reaches any machine. Composition reads registered manifests -// without ParseManifest, and judges nothing of this: refusing there would fail the whole machine's declaration and -// freeze every module on it for one module's path, where the node-engine fails only that resource. -// -// **The node-engine's rules, no more** (mesh-host's internal/apply/placement_guard.go, with files judged as -// directories are after novox/hq issue 495, rule 6). A path is refused when it is one of protectedRoots or holds -// one, when it is at or below a tree in forbiddenBelow, or at or below one of engineTrees and its module is not -// the node-engine's. What the engine judges with what only the machine knows stays the engine's: where the -// runtimes really keep their data, links, the accounts' homes, a directory's owner below /etc. The lists are the -// engine's own words, and a test (engine_guard_test.go) holds them equal to mesh-host's beside this repository. -// systemPath stays the stricter rule for a setting: a setting is an operator's word about one machine, and the -// trees it lists (/etc, /usr, /run, the mesh's own) are where the mesh's own modules write by design. -var ( - protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", - "/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin", - "/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin", - "/usr/share", "/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/run", "/var/tmp", - "/var/spool"} - forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker", - "/var/lib/containers", "/var/lib/containerd"} - engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"} -) - // engineModule is the node-engine's own module, the one that places in engineTrees. const engineModule = "mesh-host" -// enginePath says why the node-engine refuses a directory or file at path for module, or "". -func enginePath(path, module string) string { - path = filepath.Clean(path) - if !filepath.IsAbs(path) { - return "" - } - atOrBelow := func(tree string) bool { return path == tree || strings.HasPrefix(path, tree+"/") } - for _, root := range protectedRoots { - if path == root || path == "/" || strings.HasPrefix(root, path+"/") { - return root + " is one of the machine's own directories, and owning it is owning everything in it" - } - } - for _, tree := range forbiddenBelow { - if atOrBelow(tree) { - return "nothing is placed in " + tree - } - } - if module != engineModule { - for _, tree := range engineTrees { - if atOrBelow(tree) { - return tree + " is the node-engine's own, placed in by its own module alone" - } - } - } - return "" -} - -// resolvedPathProblems is every directory and file of the definition whose path, resolved as a node with the -// default root resolves it, the node-engine would refuse (novox/hq issue 496). A path still holding a placeholder -// only a machine fills (a setting, an access the definition gives no default) is the engine's to judge. -func (m Manifest) resolvedPathProblems() []string { - dirs := dirsFor(m, Rendering{}) - accesses := map[string]string{} - for _, a := range m.Accesses { - if a.ID != "" && a.Path != "" { - accesses[a.ID] = a.Path - } - } - var problems []string - for _, r := range m.Resources { - kind := fmt.Sprint(r["type"]) - if kind != "directory" && kind != "file" { - continue - } - id := fmt.Sprint(r["id"]) - path, _ := r["path"].(string) - if kind == "directory" && path == "" { - path = dirs[id] - } - path, _ = dirFill(path, dirs, m.Module) - path, _ = accessFill(path, accesses, m.Module) - if path == "" || strings.Contains(path, "${") { - continue - } - if why := enginePath(path, m.Module); why != "" { - problems = append(problems, fmt.Sprintf("%s's %s %q resolves to %s, which the node-engine refuses: %s. "+ - "A module's directories and files are judged when its definition is, so the merge gate refuses it "+ - "before any machine does (novox/hq issue 496)", m.Module, kind, id, filepath.Clean(path), why)) - } - } - return problems -} - // accessRef is how a module names one of its accesses: ${access:}. var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`) @@ -534,57 +438,6 @@ func ownerInto(resource map[string]any, placed map[string]Placement) { } } -// unknownAccessRefs is every ${access:…} in the definition that names no access the definition -// declares by id — refused where the author is, as unknownDirRefs does for directories. -func (m Manifest) unknownAccessRefs() []string { - declared := map[string]bool{} - for _, a := range m.Accesses { - if a.ID != "" { - declared[a.ID] = true - } - } - seen := map[string]bool{} - var problems []string - refuse := func(s string, where any) { - for _, match := range accessRef.FindAllStringSubmatch(s, -1) { - id := match[1] - if declared[id] || seen[id] { - continue - } - seen[id] = true - problems = append(problems, fmt.Sprintf( - "%s says ${access:%s} in %v, and declares no access %q — a reference the mesh "+ - "cannot place would reach the machine as a literal path", - m.Module, id, where, id)) - } - } - for _, r := range m.Resources { - for _, field := range []string{"path", "content"} { - if s, ok := r[field].(string); ok { - refuse(s, r["id"]) - } - } - for _, field := range []string{"volumes", "env-file"} { - if list, ok := r[field].([]any); ok { - for _, v := range list { - if s, ok := v.(string); ok { - refuse(s, r["id"]) - } - } - } - } - if env, ok := r["env"].(map[string]any); ok { - for _, v := range env { - if s, ok := v.(string); ok { - refuse(s, r["id"]) - } - } - } - } - sort.Strings(problems) - return problems -} - func directoriesOf(m Manifest) map[string]string { dirs := map[string]string{} for _, r := range m.Resources { diff --git a/go/catalogue/placement_test.go b/go/catalogue/placement_test.go index 1b19d70..ac86f13 100644 --- a/go/catalogue/placement_test.go +++ b/go/catalogue/placement_test.go @@ -161,25 +161,6 @@ func TestADefinitionsPathIsTheDefaultTheAssignmentReplaces(t *testing.T) { } } -// A reference to an access the definition does not declare is refused where the author is. -func TestAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) { - _, err := ParseManifest([]byte(`{ - "module": "arr", "version": "1", - "accesses": [{"id": "series", "mode": "read-write"}], - "resources": [ - {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, - {"id": "server", "type": "container", "name": "arr", "image": "arr:1", - "volumes": ["${access:movies}:/movies"]} - ]}`)) - if err == nil || !strings.Contains(err.Error(), "${access:movies}") { - t.Fatalf("a reference to an undeclared access was accepted: %v", err) - } - _, err = ParseManifest([]byte(`{"module": "arr", "version": "1", "accesses": [{"mode": "read"}]}`)) - if err == nil || !strings.Contains(err.Error(), "neither an id nor a path") { - t.Fatalf("an access with no id and no path was accepted: %v", err) - } -} - // **Placement is read from the placement, never from a layer's values** (novox/hq ADR 0348 clause 11, issue // 651). A `places` or `accesses` among the config values places nothing; the same in Placement does. A // mergeable file of today's form still takes every key of the layer as it is said, placement too, so it diff --git a/go/catalogue/predefined_inputs.go b/go/catalogue/predefined_inputs.go index 1552cf9..1b52013 100644 --- a/go/catalogue/predefined_inputs.go +++ b/go/catalogue/predefined_inputs.go @@ -1,41 +1 @@ package catalogue - -import "encoding/json" - -// ParseManifestForInputs reads a manifest for the inputs of its build and its fingerprint: the indexer's and the -// builder's read (novox/hq issue 1751). It is never a judge of config values. A value falling back to a predefined key -// (ADR 0348 §7) is judged against the list the mesh published when this process holds one (PublishedPredefined); -// where it holds none, as in the indexer and the builder, each key a value names is taken as the value declares it (its -// type, trusted when it is), since the list is read where a manifest is judged: the merge gate from the tree's root, -// the controller from what it published. No input of a fingerprint or a build comes from the list. Everything else -// ParseManifest refuses is refused here too. -func ParseManifestForInputs(raw []byte) (Manifest, error) { - list := PublishedPredefined() - if list == nil { - list = fallbacksOf(raw) - } - return parseManifest(raw, list) -} - -// fallbacksOf is the list of the predefined keys a manifest's config values fall back to, each as the value declares -// it. A manifest that is not JSON gives an empty list, and the parser says what is wrong with it. -func fallbacksOf(raw []byte) PredefinedList { - var m struct { - Config map[string]struct { - Type string `json:"type"` - Description string `json:"description"` - Predefined string `json:"predefined"` - Trusted bool `json:"trusted"` - } `json:"config"` - } - list := PredefinedList{} - if json.Unmarshal(raw, &m) != nil { - return list - } - for _, d := range m.Config { - if d.Predefined != "" { - list[d.Predefined] = PredefinedKey{Type: d.Type, Description: d.Description, Trusted: d.Trusted} - } - } - return list -} diff --git a/go/catalogue/predefined_inputs_test.go b/go/catalogue/predefined_inputs_test.go index 40b5c5e..d5141be 100644 --- a/go/catalogue/predefined_inputs_test.go +++ b/go/catalogue/predefined_inputs_test.go @@ -6,26 +6,6 @@ import ( "testing" ) -// The indexer and the builder hold no published list of predefined keys; a manifest whose config values fall back to -// one (claude-code, dunst, xterm at mesh-catalog 835bf22b) is still read for its build's and fingerprint's inputs -// (issue 1751). -func TestParseManifestForInputsReadsAValueFallingBackToAPredefinedKey(t *testing.T) { // issue 1751 - was := ReplacePublished(nil) // the indexer's and the builder's case: no published list - defer ReplacePublished(was) - for _, module := range []string{"claude-code", "dunst", "xterm"} { - raw, err := os.ReadFile("testdata/catalogue-835bf22b-" + module + ".module.json") - if err != nil { - t.Fatal(err) - } - if _, err := ParseManifest(raw); err == nil { - t.Fatalf("%s: ParseManifest reads it with no list; the test proves nothing", module) - } - if m, err := ParseManifestForInputs(raw); err != nil || m.Module != module { - t.Errorf("%s: %v", module, err) - } - } -} - func TestParseManifestForInputsStillRefusesWhatTheParserRefuses(t *testing.T) { // issue 1751 was := ReplacePublished(nil) defer ReplacePublished(was) @@ -34,7 +14,7 @@ func TestParseManifestForInputsStillRefusesWhatTheParserRefuses(t *testing.T) { if bad == string(raw) { t.Fatal("the fixture no longer names font-size; the test proves nothing") } - if _, err := ParseManifestForInputs([]byte(bad)); err == nil { + if _, err := decodeForTest([]byte(bad)); err == nil { t.Error("a manifest the parser refuses was read") } } diff --git a/go/catalogue/predefined_test.go b/go/catalogue/predefined_test.go index 015a8e5..396e4b4 100644 --- a/go/catalogue/predefined_test.go +++ b/go/catalogue/predefined_test.go @@ -74,37 +74,6 @@ func fallingBackTo(t *testing.T, key string) []byte { return raw } -// A manifest naming font-size registers against the list; one naming a key not on it, or of another type, is -// refused — judged against the list given, whatever the mesh published. -func TestAManifestIsJudgedAgainstTheListOfItsTree(t *testing.T) { - withPredefined(t, nil) - list, err := ParsePredefined([]byte(theFirstKeys)) - if err != nil { - t.Fatal(err) - } - if _, err := ParseManifestWith(fallingBackTo(t, "font-size"), list); err != nil { - t.Fatalf("a value falling back to font-size is refused: %v", err) - } - if _, err := ParseManifestWith(fallingBackTo(t, "font-scale"), list); err == nil || - !strings.Contains(err.Error(), `"font-scale", which is not on the list`) { - t.Fatalf("a key not on the list: %v", err) - } - m := inConfigForm() - declaration(m, "theme")["predefined"] = "font-size" - raw, _ := json.Marshal(m) - if _, err := ParseManifestWith(raw, list); err == nil || !strings.Contains(err.Error(), "which is a number") { - t.Fatalf("a key of another type: %v", err) - } - // Where no tree is read, the published list judges: none published refuses. - if _, err := ParseManifest(fallingBackTo(t, "font-size")); err == nil || !strings.Contains(err.Error(), "is not present") { - t.Fatalf("judged against no list: %v", err) - } - withPredefined(t, list) - if _, err := ParseManifest(fallingBackTo(t, "font-size")); err != nil { - t.Fatalf("judged against the published list: %v", err) - } -} - // A template's text filled from the tree keeps the list the manifest was judged against: read again, it is not // judged against the published one. func TestTemplatesFilledKeepTheListTheManifestWasJudgedAgainst(t *testing.T) { @@ -126,7 +95,7 @@ func TestTemplatesFilledKeepTheListTheManifestWasJudgedAgainst(t *testing.T) { declaration(m, "width")["runtime"] = true declaration(m, "sender")["runtime"] = true raw, _ := json.Marshal(m) - parsed, err := ParseManifestWith(raw, list) + parsed, err := decodeWithForTest(raw, list) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/prepares_test.go b/go/catalogue/prepares_test.go index eaae43b..fd7fa3c 100644 --- a/go/catalogue/prepares_test.go +++ b/go/catalogue/prepares_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "encoding/json" "fmt" "strings" "testing" @@ -119,23 +118,3 @@ func TestAModuleThatPreparesNothingGetsNoStep(t *testing.T) { } } } - -// A module whose own code the mesh cannot find has nothing to ask, and saying so where the manifest -// is read beats a declaration that quietly prepares nothing. -func TestAModuleThatPreparesAndRunsNoneOfItsOwnCodeIsRefused(t *testing.T) { - m := Manifest{ - Module: "gitea", - Prepares: true, - Resources: []map[string]any{ - // Only the upstream server it packages: nothing here runs gitea's own code. - {"id": "server", "type": "container", "name": "mesh-gitea-server", "image": "gitea/gitea@" + digest}, - }, - } - raw, err := json.Marshal(m) - if err != nil { - t.Fatal(err) - } - if _, err := ParseManifest(raw); err == nil { - t.Fatal("a module that prepares its state with nothing of its own to run was accepted") - } -} diff --git a/go/catalogue/process_bus.go b/go/catalogue/process_bus.go index cdfa936..347e244 100644 --- a/go/catalogue/process_bus.go +++ b/go/catalogue/process_bus.go @@ -4,8 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "slices" - "strings" ) // ProcessBusField is the key a process resource declares its bus on. Controller-side only: never composed @@ -46,9 +44,6 @@ const ( RemovesAssignmentsStream = "ASSIGNMENTS" ) -// BusRemovals is the closed set of what a bus account may remove. -var BusRemovals = []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs, RemovesAssignmentsStream} - // BusAccount is one account of a module's own beside its `broker`: a process's (ADR 0436) or a tools bundle's (ADR 0564 // Limits 4), by its id (the process resource's, or the bundle's binary), with what its bus says. type BusAccount struct { @@ -99,124 +94,6 @@ func ToolsBusSecret(m Manifest, name string) bool { return false } -// toolsSecretProblems refuses a tools bundle's bus account secret the runtime's account could not read: it is declared -// as an own secret `broker-`, at `${dir:}/broker-` for a directory resource every parent of which the -// runtime's account may pass (passableDir); the file itself is 0600 and the runtime account's. -func toolsSecretProblems(m Manifest) []string { - var problems []string - for _, a := range m.BusAccounts() { - if !a.Tools { - continue - } - name := "broker-" + a.ID - own, declared := m.OwnSecrets.Plain()[name] - if !declared { - problems = append(problems, fmt.Sprintf("%s's tools bundle %s declares bus and no own secret %s to hold its "+ - "account in (novox/hq ADR 0564 Limits 4)", m.Module, a.ID, name)) - continue - } - dir, _, _ := strings.Cut(strings.TrimPrefix(own.Path, "${dir:"), "}") - if !strings.HasPrefix(own.Path, "${dir:"+dir+"}/") || !passableDir(m, dir, 0) { - problems = append(problems, fmt.Sprintf("%s's own secret %s is at %s, which the node's tool runtime cannot "+ - "reach: a tools bundle's account lies in a directory resource the runtime's account may pass, every parent "+ - "included, such as {\"id\":\"tools-secret\",\"type\":\"directory\",\"path\":\"%s/%s-tools\",\"mode\":\"0711\"} "+ - "with the secret at ${dir:tools-secret}/%s; never under the module's own or mesh place, which are its "+ - "own (novox/hq ADR 0564 Limits 4)", m.Module, name, own.Path, meshStateRoot, m.Module, name)) - } - } - return problems -} - -// meshStateRoot is the mesh's directory of every module's mesh place on a node of the default data root: root's, mode -// 0755, so the runtime's account passes it, while each module's own place beneath it is 0700. -const meshStateRoot = defaultDataRoot + "/" + placeMesh - -// passableDir says the runtime's account may pass the directory resource id and every parent the mesh makes for it: its -// own mode lets others pass (the last digit odd), and it is either stated directly under meshStateRoot or beneath a -// placed directory that is passable in turn. A pathless directory resolves under the module's own root or its mesh -// place, which are the module's own, so it never is. -func passableDir(m Manifest, id string, depth int) bool { - if depth > 4 { - return false - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "directory" || fmt.Sprint(r["id"]) != id { - continue - } - mode, _ := r["mode"].(string) - if mode == "" || !strings.ContainsAny(mode[len(mode)-1:], "1357") { - return false - } - path, _ := r["path"].(string) - switch { - case strings.HasPrefix(path, "${dir:"): - parent, _, _ := strings.Cut(strings.TrimPrefix(path, "${dir:"), "}") - return passableDir(m, parent, depth+1) - case strings.HasPrefix(path, "/"): - return filepathDir(strings.TrimRight(path, "/")) == meshStateRoot - } - return false - } - return false -} - -func filepathDir(p string) string { - if i := strings.LastIndex(p, "/"); i > 0 { - return p[:i] - } - return "/" -} - -// toolsBusProblems is what is wrong with a tools bundle's bus (ADR 0564 Limits 4): a bus on anything but a bundle -// compiled to a binary the runtime loads, a removal outside the closed set, or a removal beside any other grant or by a -// module that does not hold mesh-broker. Every other field is judged as a process's. -func toolsBusProblems(m Manifest) []string { - var problems []string - var artifacts []Artifact - if m.Build != nil { - artifacts = m.Build.Artifacts - } - claimsBroker := false - for _, c := range m.Claims { - claimsBroker = claimsBroker || c.Name == "mesh-broker" - } - for _, a := range artifacts { - if a.Bus == nil { - continue - } - if a.Kind != ArtifactBundle || BinaryOf(a) == "" { - problems = append(problems, fmt.Sprintf("%s's artifact %s declares bus, and only a tools bundle compiled to a "+ - "binary has an account of its own (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) - continue - } - b := *a.Bus - if len(b.Queues) > 0 || b.Asks { - problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus consumes a queue or reads asks; a tools "+ - "bundle runs per call and holds no consumer (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) - } - for _, r := range b.Removes { - if !slices.Contains(BusRemovals, r) { - problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes %q, which is not one of %s "+ - "(novox/hq ADR 0564 Limits 4)", m.Module, a.Name, r, strings.Join(BusRemovals, ", "))) - } - } - if len(b.Removes) > 0 && (!claimsBroker || len(b.Emits)+len(b.State)+len(b.Invokes) > 0) { - problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes from the bus; only the bus "+ - "module's (the holder of mesh-broker) may, and with nothing else (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) - } - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "process" { - continue - } - if b, present, err := processBusOf(r); present && err == nil && len(b.Removes) > 0 { - problems = append(problems, fmt.Sprintf("%s's process %v's bus removes from the bus; only a tools bundle's "+ - "may (novox/hq ADR 0564 Limits 4)", m.Module, r["id"])) - } - } - return problems -} - // UnmarshalJSON reads a process's bus strictly: an unknown key — serves, a seat to hold or claim, or // anything else that would answer a tool call or a seat's verb — is refused here, by name (novox/hq ADR // 0436 §3). @@ -268,108 +145,3 @@ func (m Manifest) ProcessBuses() map[string]ProcessBus { } return out } - -// ProcessBusProblems is what is wrong with a manifest's processes' bus declarations (novox/hq ADR 0436 §1, -// §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or -// seat verb's shape on invokeProblems. -func ProcessBusProblems(m Manifest) []string { - problems := append(toolsBusProblems(m), toolsSecretProblems(m)...) - streams := map[string]bool{} - for _, s := range m.Streams { - streams[s.Name] = true - } - state := map[string]bool{} - for _, s := range m.State { - state[s.Name] = true - } - emits := map[string]bool{} - for _, e := range m.EmitsAll() { - emits[e] = true - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "process" { - continue - } - id := fmt.Sprint(r["id"]) - if _, present := r["bus"]; !present { - continue - } - b, _, err := processBusOf(r) - if err != nil { - problems = append(problems, fmt.Sprintf("%s's process %s declares bus: %v", m.Module, id, err)) - continue - } - user, _ := r["user"].(string) - problems = append(problems, processAccountProblems(m, id, user)...) - for _, e := range b.Emits { - if !emits[e] { - problems = append(problems, fmt.Sprintf( - "%s's process %s's bus emits %q, which %s does not declare as its own (novox/hq ADR 0436 §1)", - m.Module, id, e, m.Module)) - } - } - for _, q := range b.Queues { - if !streams[q] { - problems = append(problems, fmt.Sprintf( - "%s's process %s's bus queues %q, which %s does not declare as its own stream (novox/hq "+ - "ADR 0436 §1)", m.Module, id, q, m.Module)) - } - } - for _, s := range b.State { - if !state[s] { - problems = append(problems, fmt.Sprintf( - "%s's process %s's bus reads and writes state %q, which %s does not declare as its own "+ - "(novox/hq ADR 0436 §1)", m.Module, id, s, m.Module)) - } - } - for _, t := range b.Invokes { - if !invokeNameValid(t) { - problems = append(problems, fmt.Sprintf( - "%s's process %s's bus invokes %q, which does not name a tool: a process invokes "+ - "., or a role's seat:. (novox/hq ADR 0436 §1)", m.Module, id, t)) - } - } - } - return problems -} - -// processAccountProblems is what is wrong with one process's bus account (novox/hq ADR 0436 §1, §2): an -// account the module makes — a `user` resource of that name — never root and never the operator account, -// which a manifest cannot say safely (a node with no account has nothing to resolve it to, novox/hq ADR -// 0436, settled by the lead). -func processAccountProblems(m Manifest, processID, user string) []string { - switch { - case user == "": - return []string{fmt.Sprintf( - "%s's process %s declares bus and names no account; a process account is a user resource the "+ - "module makes, never root and never the operator account (novox/hq ADR 0436 §1, §2)", - m.Module, processID)} - case user == "root": - return []string{fmt.Sprintf( - "%s's process %s runs as root; a process account is the module's own, never root (novox/hq ADR "+ - "0436 §1, §2)", m.Module, processID)} - case user == "${machine:account}": - return []string{fmt.Sprintf( - "%s's process %s runs as the operator account; a process account is the module's own, never the "+ - "operator's (novox/hq ADR 0436 §1, §2)", m.Module, processID)} - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == user { - return nil - } - } - return []string{fmt.Sprintf( - "%s's process %s runs as %s, which it does not make: a user resource named %s (novox/hq ADR 0436 §1, "+ - "§2)", m.Module, processID, user, user)} -} - -// invokeNameValid says a module or process's invoked entry names a tool: ., a role's -// seat:., or * for every tool on the mesh (novox/hq ADR 0152, ADR 0436 §1). -func invokeNameValid(t string) bool { - if t == "*" { - return true - } - t = strings.TrimPrefix(t, "seat:") - first, second, named := strings.Cut(t, ".") - return named && name.MatchString(first) && toolName.MatchString(second) -} diff --git a/go/catalogue/process_bus_test.go b/go/catalogue/process_bus_test.go index 3c1977c..e3a2f33 100644 --- a/go/catalogue/process_bus_test.go +++ b/go/catalogue/process_bus_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -30,84 +29,6 @@ func mailSenderProcessManifest(t *testing.T, bus string) string { const mailSenderBus = `{"emits":["mail.send.sent","mail.send.failed"],"queues":["mail-sends"],` + `"state":["sends"],"asks":true,"invokes":["seat:operator-channel.tell"]}` -// 1179/AC1: a well-formed bus on a process whose account the module makes is accepted, and the catalogue -// gives it whole to the grant composition (ProcessBuses, which 1180 reads). -func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) { - m, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus))) - if err != nil { - t.Fatalf("a well-formed process bus was refused: %v", err) - } - buses := m.ProcessBuses() - b, ok := buses["send"] - if !ok { - t.Fatalf("the process's bus was not given to the grant composition: %+v", buses) - } - if !strSliceEqual(b.Emits, []string{"mail.send.sent", "mail.send.failed"}) || - !strSliceEqual(b.Queues, []string{"mail-sends"}) || !strSliceEqual(b.State, []string{"sends"}) || - !b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) { - t.Fatalf("the bus read as %+v", b) - } - - // 1952 S5 (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): a tools bundle declares its own bus account, read as a - // process's, named after its binary and kept through resolution; a removal is from the closed set, alone, and the - // bus module's (mesh-broker) alone. - toolsIn := func(claims, bus, mode, where string) string { - return `{"module":"nats","version":"1","claims":` + claims + `,"own-secrets":{"broker":"${dir:mesh-state}/broker",` + - `"broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},"resources":[{"id":"mesh-state","type":"directory",` + - `"mode":"0700","place":"mesh","unknown":"program"},{"id":"tools-secret","type":"directory","mode":"` + mode + - `","path":"` + where + `","unknown":"program"}],"build":{"artifacts":[{"name":"tools","kind":"bundle",` + - `"language":"go","system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":` + bus + `}]}}` - } - tools := func(claims, bus string) string { return toolsIn(claims, bus, "0711", "/var/lib/mesh/nats-tools") } - broker := `[{"name":"mesh-broker","scope":"mesh"}]` - for _, c := range []struct { - name, manifest, refused string - }{ - {name: "the bus module's tools removing", manifest: tools(broker, `{"removes":["DEAD_LETTER_NOTICES.controller","mesh-host_acts","ASSIGNMENTS"]}`)}, - {name: "a removal outside the set", manifest: tools(broker, `{"removes":["EVENTS.a_m"]}`), refused: "not one of"}, - {name: "a removal beside a grant", manifest: tools(broker, `{"removes":["mesh-host_acts"],"invokes":["nats.nats_streams"]}`), refused: "with nothing else"}, - {name: "another module's tools removing", manifest: tools(`[]`, `{"removes":["mesh-host_acts"]}`), refused: "only the bus module"}, - {name: "a tools bundle consuming a queue", manifest: tools(broker, `{"queues":["x"]}`), refused: "holds no consumer"}, - {name: "a process removing", manifest: mailSenderProcessManifest(t, `{"removes":["mesh-host_acts"]}`), refused: "only a tools bundle"}, - // The runtime's account reads the account's secret: it lies where others may pass (the host agent, 2026-10-29). - {name: "a tools secret the runtime cannot reach", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0700", - "/var/lib/mesh/nats-tools"), refused: "cannot reach"}, - // Its parents too: under the module's mesh place, /var/lib/mesh/nats is 0700 (the lead, read on novox 2026-10-29). - {name: "a tools secret beneath the module's mesh place", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0711", - "${dir:mesh-state}/tools"), refused: "cannot reach"}, - } { - m, err := ParseManifest([]byte(c.manifest)) - if c.refused != "" { - if err == nil || !strings.Contains(err.Error(), c.refused) { - t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused) - } - continue - } - if err != nil { - t.Fatalf("%s: %v", c.name, err) - } - accounts := m.BusAccounts() - if len(accounts) != 1 || accounts[0].ID != "nats-tools" || !accounts[0].Tools || - !strSliceEqual(accounts[0].Bus.Removes, []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs, RemovesAssignmentsStream}) { - t.Errorf("%s: the accounts are %+v", c.name, accounts) - } - // Its secret's file is the runtime account's, as the runtime's own credential is; the module's other secrets stay - // its own. - if (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools") != "ops" || - (Resolution{Account: "ops"}).ownSecretOwner(m, "broker") != "" || (Resolution{}).ownSecretOwner(m, "broker-nats-tools") != "" { - t.Errorf("%s: the tools secret's owner is %q", c.name, (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools")) - } - // Resolved, the build goes and its bundles keep the bus (build.go). - resolved := Manifest{Module: "nats", Bundles: []Bundle{{Name: "tools", Binary: "nats-tools", Bus: m.Build.Artifacts[0].Bus}}} - if got := resolved.BusAccounts(); len(got) != 1 || got[0].ID != "nats-tools" { - t.Errorf("%s: resolved, the accounts are %+v", c.name, got) - } - } - if got := m.BusAccounts(); len(got) != 1 || got[0].ID != "send" || got[0].Tools { - t.Errorf("the process's account is %+v", got) - } -} - func strSliceEqual(a, b []string) bool { if len(a) != len(b) { return false @@ -120,72 +41,14 @@ func strSliceEqual(a, b []string) bool { return true } -// 1179/AC2: a bus on a process with no user, user root, or the operator account is refused naming the -// process and the account rule. -func TestAProcessBusAccountMustBeTheModulesOwn(t *testing.T) { - for name, c := range map[string]struct{ user, says string }{ - "no user": {"", "send"}, - "user root": {"root", "root"}, - "the operator account": {"${machine:account}", "operator"}, - } { - raw := `{"module":"mesh-mail","version":"1","emits":["mail.send.sent"],"state":["sends"],` + - `"streams":[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + - `"max-deliveries":5}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + - `{"id":"send","type":"process","run":["./mesh-mail","send"]` - if c.user != "" { - raw += `,"user":"` + c.user + `"` - } - raw += `,"bus":{"emits":["mail.send.sent"]}}]}` - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "send") || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming the process and %q", name, err, c.says) - } - } -} - -// 1179/AC3: a bus naming a stream, state or event the module does not declare as its own is refused naming -// it. -func TestAProcessBusNamesOnlyTheModulesOwn(t *testing.T) { - for name, c := range map[string]struct{ bus, says string }{ - "another module's event": {`{"emits":["billing.paid"]}`, "billing.paid"}, - "another module's queue": {`{"queues":["other-queue"]}`, "other-queue"}, - "another module's state": {`{"state":["other-state"]}`, "other-state"}, - } { - _, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus))) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) - } - } -} - -// 1179/AC4: a bus declaring serves, holds, claims or a seat is refused naming the key; a bus with invokes of -// a tool or a seat verb is accepted. -func TestAProcessBusNeverServesOrHoldsASeat(t *testing.T) { - for name, c := range map[string]struct{ bus, says string }{ - "serves": {`{"emits":["mail.send.sent"],"serves":["send"]}`, "serves"}, - "holds": {`{"emits":["mail.send.sent"],"holds":["mail-sender"]}`, "holds"}, - "claims": {`{"emits":["mail.send.sent"],"claims":["mail-sender"]}`, "claims"}, - "seat": {`{"emits":["mail.send.sent"],"seat":"mail-sender"}`, "seat"}, - } { - _, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus))) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) - } - } - accepted := `{"emits":["mail.send.sent","mail.send.failed"],"invokes":["seat:operator-channel.tell","mail.status"]}` - if _, err := ParseManifest([]byte(mailSenderProcessManifest(t, accepted))); err != nil { - t.Fatalf("a bus invoking a tool and a seat verb was refused: %v", err) - } -} - // 1179/AC5: a process declaring bus is composed with no bus key on the wire, and is otherwise byte for byte // what main composes for the same process without bus. func TestAProcessesBusNeverReachesTheWire(t *testing.T) { - withBus, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus))) + withBus, err := decodeForTest([]byte(mailSenderProcessManifest(t, mailSenderBus))) if err != nil { t.Fatal(err) } - withoutBus, err := ParseManifest([]byte(mailSenderProcessManifest(t, ""))) + withoutBus, err := decodeForTest([]byte(mailSenderProcessManifest(t, ""))) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/provided_test.go b/go/catalogue/provided_test.go index f09c0e9..e011047 100644 --- a/go/catalogue/provided_test.go +++ b/go/catalogue/provided_test.go @@ -25,7 +25,7 @@ func provided(t *testing.T) map[string]catalogue.Manifest { if err != nil { t.Fatal(err) } - m, err := catalogue.ParseManifest(b) + m, err := catalogue.Decode(b) if err != nil { t.Fatalf("a manifest this control plane ships is not valid: %v", err) } diff --git a/go/catalogue/public_issuer_test.go b/go/catalogue/public_issuer_test.go index ec8e992..afa71a3 100644 --- a/go/catalogue/public_issuer_test.go +++ b/go/catalogue/public_issuer_test.go @@ -27,7 +27,7 @@ func TestTheRetiredNetworkingModulesAreNotInTheCatalogue(t *testing.T) { if err != nil { continue } - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { continue // judged by the catalogue's own tests } diff --git a/go/catalogue/published_ports_test.go b/go/catalogue/published_ports_test.go index 48036d2..8c3c70b 100644 --- a/go/catalogue/published_ports_test.go +++ b/go/catalogue/published_ports_test.go @@ -38,7 +38,7 @@ func TestEveryPublishedPortIsOneItsModuleDeclares(t *testing.T) { if err != nil { continue } - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { // Whether every manifest parses is TestEveryCatalogueManifestParses's question. continue diff --git a/go/catalogue/reads.go b/go/catalogue/reads.go index 93686a2..224e464 100644 --- a/go/catalogue/reads.go +++ b/go/catalogue/reads.go @@ -87,34 +87,6 @@ func readsVerbs() []Verb { } } -// ReadsHolderProblems is what a holder of node-reads is held to (novox/hq ADR 0310 §3): it reads as an -// account of its own, so the node's file modes bound what it sees. Carried by the machine's runtime it would -// read as the operator's account, which sees the operator's keys and the agent's credentials. -func ReadsHolderProblems(m Manifest) []string { - if !m.ClaimsSeat(ReadsSeat) { - return nil - } - if m.RunsAs == "" { - return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+ - "bounded by that account's file modes, and the machine's runtime runs as the operator's account "+ - "(novox/hq ADR 0310 §3)", m.Module, ReadsSeat)} - } - // In no group beside its own: a group is a way to a secret (docker's is root's), and the account's file modes - // are what bound its reads. - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "user" || fmt.Sprint(r["name"]) != m.RunsAs { - continue - } - if groups, said := r["groups"]; said { - if list, ok := groups.([]any); !ok || len(list) > 0 { - return []string{fmt.Sprintf("%s claims %s, and its account %s is put in groups %v: the account that "+ - "reads is in no group beside its own (novox/hq ADR 0310 §3)", m.Module, ReadsSeat, m.RunsAs, groups)} - } - } - } - return nil -} - // secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310 // §5): the directory of every module's own secret, every credential file a module is given and every grant // directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed diff --git a/go/catalogue/reads_test.go b/go/catalogue/reads_test.go index 69f314f..0b38aba 100644 --- a/go/catalogue/reads_test.go +++ b/go/catalogue/reads_test.go @@ -36,20 +36,6 @@ func TestNodeReadsIsANodeSeatWhoseVerbsSayWhatTheyReplace(t *testing.T) { } } -// novox/hq ADR 0310 §3: a holder of node-reads reads as an account of its own; one the machine's runtime would -// carry — as the operator's account — is refused at parse. -func TestAHolderOfNodeReadsWithoutAnAccountOfItsOwnIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module": "reads", "version": "1", - "claims": [{"name": "node-reads", "scope": "node"}]}`)) - if err == nil || !strings.Contains(err.Error(), "runs-as") || !strings.Contains(err.Error(), "0310") { - t.Errorf("a holder of node-reads without runs-as was not refused for it: %v", err) - } - if p := ReadsHolderProblems(Manifest{Module: "reads", RunsAs: "mesh-reads", - Claims: []Claim{{Name: ReadsSeat}}}); len(p) != 0 { - t.Errorf("a holder running as its own account was refused: %v", p) - } -} - // novox/hq ADR 0310 §5: the holder is told, by the composition, where its node keeps a secret — the directory // of every module's own secret, every credential file and grant directory, the vault's directories and the // place the operator-sealed secrets are kept — and no other module is told. @@ -122,16 +108,3 @@ func TestASecretPlaceThatCannotBeResolvedFailsTheComposition(t *testing.T) { t.Error("a relative place was dropped") } } - -// ADR 0310 §3: the account that reads is in no group beside its own; one put in a group is refused. -func TestTheReadsAccountIsInNoGroup(t *testing.T) { - m := Manifest{Module: "reads", RunsAs: "mesh-reads", Claims: []Claim{{Name: ReadsSeat}}, - Resources: []map[string]any{{"id": "account", "type": "user", "name": "mesh-reads", "groups": []any{"docker"}}}} - if p := ReadsHolderProblems(m); len(p) != 1 || !strings.Contains(p[0], "docker") { - t.Errorf("an account in docker was not refused: %v", p) - } - m.Resources[0]["groups"] = []any{} - if p := ReadsHolderProblems(m); len(p) != 0 { - t.Errorf("an account in no group was refused: %v", p) - } -} diff --git a/go/catalogue/replaces_test.go b/go/catalogue/replaces_test.go index 4b34320..5815795 100644 --- a/go/catalogue/replaces_test.go +++ b/go/catalogue/replaces_test.go @@ -53,7 +53,7 @@ func TestEveryReplacedCommandIsOneShortLine(t *testing.T) { // A verb's definition in a manifest may say what it replaces, like the mesh's own. func TestAVerbDefinitionCarriesWhatItReplaces(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["refund"],` + + m, err := decodeForTest([]byte(`{"module":"till","version":"1","tools":["refund"],` + `"seats":[{"name":"shop-till","serves":[{"name":"refund","replaces":["psql -c refund"]}]}],` + `"claims":[{"name":"shop-till","scope":"mesh"}]}`)) if err != nil { @@ -64,34 +64,6 @@ func TestAVerbDefinitionCarriesWhatItReplaces(t *testing.T) { } } -// A module says what its own tools replace, and only for a tool it declares. -func TestAModuleSaysWhatItsOwnToolsReplace(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"docker","version":"1","tools":["docker_logs","docker_list"],` + - `"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}}`)) - if err != nil { - t.Fatal(err) - } - if m.Replaces["docker_logs"][0] != "docker logs" || m.Replaces["docker_list"][0] != "docker ps" { - t.Fatalf("replaces not read: %v", m.Replaces) - } - for _, c := range []struct{ manifest, says string }{ - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_lgos":["docker logs"]}}`, - "declares no such tool"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[]}}`, "replaces nothing"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[" "]}}`, "empty command"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs\nrm -rf /"]}}`, - "more than one line"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs","docker logs"]}}`, - "twice"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["` + strings.Repeat("x", MaxReplaced+1) + `"]}}`, - "longer than"}, - } { - if _, err := ParseManifest([]byte(c.manifest)); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: want a refusal saying %q, got %v", c.manifest, c.says, err) - } - } -} - // A seat row the store seeded before `replaces` existed carries none; the working set takes the compiled // one, so the `tools` answer says it on a mesh whose rows exist. func TestASeededRowTakesWhatItsVerbsReplaceFromTheCompiledSeat(t *testing.T) { diff --git a/go/catalogue/replay166_test.go b/go/catalogue/replay166_test.go index 7611666..78e10d1 100644 --- a/go/catalogue/replay166_test.go +++ b/go/catalogue/replay166_test.go @@ -12,7 +12,7 @@ import ( // ParseManifest, Resolve, and the resolve tests' shelf, reachable machine and names, so it is laid over the // commit before. func TestReplay166(t *testing.T) { - grafana, err := ParseManifest([]byte(`{"module":"grafana","version":"1", + grafana, err := decodeForTest([]byte(`{"module":"grafana","version":"1", "requires":[{"name":"influxdb-api","optional":true}]}`)) if err != nil { t.Fatalf("a manifest cannot say grafana runs without influxdb-api: %v", err) diff --git a/go/catalogue/replay412_test.go b/go/catalogue/replay412_test.go index 178667d..1b52013 100644 --- a/go/catalogue/replay412_test.go +++ b/go/catalogue/replay412_test.go @@ -1,36 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// Replay of novox/hq issue 412 (2026-10-10): which module owns /etc/hosts was enforced only after the fact, -// when two modules on one machine declared it; a module that did not claim node-hostname could declare -// /etc/hosts, and a module could claim node-hostname declaring none of its paths, and nothing refused either. -// Written only with what the catalogue had before its fix (mesh-controller #267): CatalogueProblems, Shelf, -// SeatNamed and CanHold, so it is laid over the commit before. -func TestReplay412(t *testing.T) { - stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ - {"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}} - got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ") - if !strings.Contains(got, "node-hostname") { - t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) - } - - seat, ok := SeatNamed("node-hostname") - if !ok { - t.Fatal("no node-hostname seat") - } - holder := Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode, - Serves: []string{"entries", "add", "remove"}}}} - err := CanHold(holder, seat) - if err == nil { - t.Fatal("a holder of node-hostname declaring neither /etc/hosts nor /etc/hostname was accepted") - } - for _, want := range []string{"/etc/hosts", "/etc/hostname"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not name %s: %v", want, err) - } - } -} diff --git a/go/catalogue/resolve_keeps_references_test.go b/go/catalogue/resolve_keeps_references_test.go index 24eb07c..1b52013 100644 --- a/go/catalogue/resolve_keeps_references_test.go +++ b/go/catalogue/resolve_keeps_references_test.go @@ -1,42 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A resolved manifest keeps a built artifact's reference in the store-relative form, never the -// address the builder reached the store by (novox/hq ADR 0155): on 2026-10-02 the first bundle -// resolved on the mesh carried the store's host in bundles[0].source and registration refused it -// as naming an installation. Archive resources are the same kind of reference and get the same. -func TestAResolvedReferenceIsKeptNotRouted(t *testing.T) { - m, err := ParseManifest([]byte(`{ - "module": "sample", "version": "1", - "tools": ["one"], - "build": {"artifacts": [ - {"name": "code", "kind": "bundle", "language": "typescript", "entrypoints": ["tools/index.js"]}, - {"name": "files", "kind": "archive", "from": "files"} - ]}, - "resources": [{"id": "packed", "type": "archive", "path": "/opt/sample", "artifact": "files"}] - }`)) - if err != nil { - t.Fatal(err) - } - digest := "sha256:" + strings.Repeat("ab", 32) - resolved, err := m.Resolve([]Built{ - {Name: "code", Kind: ArtifactBundle, Reference: "http://store.example:5100/v2/sample/code/blobs/" + digest, Digest: digest}, - {Name: "files", Kind: ArtifactArchive, Reference: "http://store.example:5100/v2/sample/files/blobs/" + digest, Digest: digest}, - }) - if err != nil { - t.Fatal(err) - } - if got, want := resolved.Bundles[0].Source, ArtifactStoreScheme+"sample/code/blobs/"+digest; got != want { - t.Errorf("bundle source %q, want the kept form %q", got, want) - } - if got, want := resolved.Resources[0]["source"], ArtifactStoreScheme+"sample/files/blobs/"+digest; got != want { - t.Errorf("archive source %q, want the kept form %q", got, want) - } - if problems := InstallationProblems(resolved); len(problems) != 0 { - t.Errorf("a resolved manifest names an installation: %v", problems) - } -} diff --git a/go/catalogue/resolved_path_test.go b/go/catalogue/resolved_path_test.go index 55b5519..bec366f 100644 --- a/go/catalogue/resolved_path_test.go +++ b/go/catalogue/resolved_path_test.go @@ -7,27 +7,12 @@ package catalogue // merge gate's reading of a changed repository all run, so a refusal here is a refusal at each of them. import ( - "strings" "testing" ) -func TestADirectoryPlacedInDockersDataIsRefusedWhereTheDefinitionIsJudged(t *testing.T) { - // The #205 shape, as it was merged. - _, err := ParseManifest([]byte(`{"module": "docker", "version": "1", - "resources": [{"id": "state", "type": "directory", "place": "."}]}`)) - if err == nil { - t.Fatal("a directory resolving to /var/lib/docker was accepted; the node-engine refuses it at apply") - } - for _, said := range []string{"docker", `"state"`, "/var/lib/docker", "issue 496"} { - if !strings.Contains(err.Error(), said) { - t.Errorf("the refusal names the module, the resource, the path and why; %q is missing from %q", said, err) - } - } -} - func TestTheMeshsPlaceForDockerIsAccepted(t *testing.T) { // The fix #205 needed: the mesh's own directory for the module, /mesh/docker. - m, err := ParseManifest([]byte(`{"module": "docker", "version": "1", "resources": [ + m, err := decodeForTest([]byte(`{"module": "docker", "version": "1", "resources": [ {"id": "state", "type": "directory", "place": "mesh"}, {"id": "marker", "type": "file", "path": "${dir:state}/applied", "content": "x"}]}`)) if err != nil { @@ -38,82 +23,6 @@ func TestTheMeshsPlaceForDockerIsAccepted(t *testing.T) { } } -// The node-engine's rules, for directories and — as the engine judges them since novox/hq issue 495 — files. -func TestADefinitionIsRefusedWhereTheNodeEngineRefusesItsPaths(t *testing.T) { - refused := map[string]string{ - "a stated directory in docker's data": `{"module": "sidecar", "version": "1", "resources": [ - {"id": "volumes", "type": "directory", "path": "/var/lib/docker/volumes/x"}]}`, - "a file in containerd's data": `{"module": "images", "version": "1", "resources": [ - {"id": "f", "type": "file", "path": "/var/lib/containerd/x", "content": "x"}]}`, - "a file beneath a placed directory that climbs out of it": `{"module": "docker", "version": "1", "resources": [ - {"id": "state", "type": "directory", "place": "mesh"}, - {"id": "f", "type": "file", "path": "${dir:state}/../../containers/x", "content": "x"}]}`, - "a file in /boot": `{"module": "grub", "version": "1", "resources": [ - {"id": "cfg", "type": "file", "path": "/boot/grub/custom.cfg", "content": "x"}]}`, - "a directory that is the mesh's whole tree": `{"module": "mesh", "version": "1", "resources": [ - {"id": "all", "type": "directory", "place": "."}]}`, - "a directory that holds /etc": `{"module": "x", "version": "1", "resources": [ - {"id": "d", "type": "directory", "path": "/"}]}`, - "a directory in the node-engine's own tree, by another module": `{"module": "intruder", "version": "1", - "resources": [{"id": "d", "type": "directory", "path": "/var/lib/mesh-host/x"}]}`, - } - for name, raw := range refused { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "issue 496") { - t.Errorf("%s: accepted, or refused for another reason: %v", name, err) - } - } - - // What the engine applies, the mesh's own modules' paths among them (read from every machine's live plan): - // the machine's configuration, run directories, programs below /usr/local, the node-engine's own trees by its - // own module, an account's keys, a module's own root, and — not on the engine's lists, so not refused here — - // below /lib and at /storage, /data, /services and /var/lock. - accepted := map[string]string{ - "a unit in /etc": `{"module": "power", "version": "1", "resources": [ - {"id": "d", "type": "directory", "path": "/etc/systemd/system/x.service.d"}, - {"id": "u", "type": "file", "path": "/etc/systemd/system/x.service.d/a.conf", "content": "x"}]}`, - "a run directory": `{"module": "fail2ban", "version": "1", "resources": [ - {"id": "run-dir", "type": "directory", "path": "/var/run/fail2ban"}]}`, - "a program in /usr/local/bin": `{"module": "claude-code", "version": "1", "resources": [ - {"id": "start", "type": "file", "path": "/usr/local/bin/claude-agent", "content": "x"}]}`, - "the node-engine's launcher and state, by the node-engine": `{"module": "mesh-host", "version": "1", "resources": [ - {"id": "launcher", "type": "file", "path": "/usr/lib/nox-mesh-host/launch", "content": "x"}, - {"id": "state", "type": "directory", "path": "/var/lib/mesh-host"}]}`, - "an account's .ssh": `{"module": "ssh-client", "version": "1", "resources": [ - {"id": "ssh-dir", "type": "directory", "path": "/home/someone/.ssh"}, - {"id": "config", "type": "file", "path": "/home/someone/.ssh/config", "content": "x"}]}`, - "a module's own root": `{"module": "mailu", "version": "1", "resources": [ - {"id": "state", "type": "directory", "place": "."}]}`, - "a file below /lib": `{"module": "udev", "version": "1", "resources": [ - {"id": "rule", "type": "file", "path": "/lib/udev/rules.d/99-x.rules", "content": "x"}]}`, - "directories at /storage, /data, /services and /var/lock": `{"module": "roots", "version": "1", "resources": [ - {"id": "a", "type": "directory", "path": "/storage"}, {"id": "b", "type": "directory", "path": "/data"}, - {"id": "c", "type": "directory", "path": "/services"}, {"id": "d", "type": "directory", "path": "/var/lock"}]}`, - } - for name, raw := range accepted { - if _, err := ParseManifest([]byte(raw)); err != nil { - t.Errorf("%s: refused: %v", name, err) - } - } -} - -func TestAPathThroughAnAccessIsJudgedWithTheAccessFilledIn(t *testing.T) { - // A file's path may name an access; the access's default path is the definition's, so it is judged with it. - _, err := ParseManifest([]byte(`{"module": "backup", "version": "1", - "accesses": [{"id": "images", "path": "/var/lib/docker/volumes"}], - "resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)) - if err == nil || !strings.Contains(err.Error(), "/var/lib/docker/volumes/marker") || - !strings.Contains(err.Error(), "issue 496") { - t.Fatalf("a file reaching Docker's data through an access's default path was accepted: %v", err) - } - // An access the definition gives no path is placed by a setting, which Places and AccessPlaces judge, and on - // the machine by the node-engine: nothing here to resolve it against, so nothing is refused for it. - if _, err := ParseManifest([]byte(`{"module": "backup", "version": "1", - "accesses": [{"id": "images"}], - "resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)); err != nil { - t.Fatalf("an access placed only by a setting cannot be judged at the definition: %v", err) - } -} - func TestComposingAMachineIsNeverStoppedByOneModulesPath(t *testing.T) { // A module registered from outside the catalogue never passes the gate. Refusing its path while a machine's // declaration is composed would fail the whole declaration and freeze every module on that machine; the diff --git a/go/catalogue/runonce_test.go b/go/catalogue/runonce_test.go index 9b17a58..d2c066b 100644 --- a/go/catalogue/runonce_test.go +++ b/go/catalogue/runonce_test.go @@ -56,27 +56,6 @@ func TestARunOnceContainerRendersBeforeTheContainerItGates(t *testing.T) { } } -func TestARunOnceMustBeABoolean(t *testing.T) { - // A value that is not true or false is refused here, not sent to a machine that would then have - // to guess what a string means. - digest := "@sha256:" + strings.Repeat("a", 64) - bad := []byte(`{"module":"m","resources":[ - {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":"yes"} - ]}`) - if _, err := ParseManifest(bad); err == nil { - t.Error("a run-once that is not a boolean was accepted") - } else if !strings.Contains(err.Error(), "run-once") { - t.Errorf("refused for the wrong reason: %v", err) - } - - good := []byte(`{"module":"m","resources":[ - {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":true} - ]}`) - if _, err := ParseManifest(good); err != nil { - t.Errorf("a valid run-once container was refused: %v", err) - } -} - func TestARunOnceStepMayNameWhatItReads(t *testing.T) { // For a step, restart-on means *run again* when what it reads changed: a gate that fetches a // provider's root names the binding file it reads, so a provider that moved is fetched again @@ -86,7 +65,7 @@ func TestARunOnceStepMayNameWhatItReads(t *testing.T) { {"id":"conf","type":"file","path":"/x","content":"y"}, {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":true,"restart-on":["conf"]} ]}`) - if _, err := ParseManifest(good); err != nil { + if _, err := decodeForTest(good); err != nil { t.Errorf("a run-once step naming what it reads was refused: %v", err) } } diff --git a/go/catalogue/runtime_gate_test.go b/go/catalogue/runtime_gate_test.go index d023a35..cc4ddfb 100644 --- a/go/catalogue/runtime_gate_test.go +++ b/go/catalogue/runtime_gate_test.go @@ -39,7 +39,7 @@ const thePacketFilterAsItWas = `{ }` func TestAToolContainerOnTheRuntimeImageIsNamedForWhatItIs(t *testing.T) { - m, err := ParseManifest([]byte(thePacketFilterAsItWas)) + m, err := decodeForTest([]byte(thePacketFilterAsItWas)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/runtime_health_test.go b/go/catalogue/runtime_health_test.go index 7f3a2b1..d2b075f 100644 --- a/go/catalogue/runtime_health_test.go +++ b/go/catalogue/runtime_health_test.go @@ -2,7 +2,6 @@ package catalogue import ( "encoding/json" - "strings" "testing" "git.novox.be/novox/mesh-sdk/go/validate" @@ -34,7 +33,7 @@ func ownAccountManifest(t *testing.T, runsAs bool, health any) []byte { } func TestAModuleOfItsOwnAccountDeclaresItsRuntimesHealthByItsOwnTool(t *testing.T) { - m, err := ParseManifest(ownAccountManifest(t, true, map[string]any{"kind": "tool", "tool": "keeper_health"})) + m, err := decodeForTest(ownAccountManifest(t, true, map[string]any{"kind": "tool", "tool": "keeper_health"})) if err != nil { t.Fatalf("a tool check on its own runtime was refused: %v", err) } @@ -46,31 +45,6 @@ func TestAModuleOfItsOwnAccountDeclaresItsRuntimesHealthByItsOwnTool(t *testing. } } -func TestRuntimeHealthIsRefusedWithoutAnAccountOfItsOwn(t *testing.T) { - _, err := ParseManifest(ownAccountManifest(t, false, map[string]any{"kind": "tool", "tool": "keeper_health"})) - if err == nil || !strings.Contains(err.Error(), "runtime-health") || !strings.Contains(err.Error(), "runs-as") { - t.Errorf("runtime-health without runs-as: %v", err) - } -} - -func TestARuntimeHealthIsHeldToTheHealthRules(t *testing.T) { - for _, c := range []struct { - health any - says string - }{ - {map[string]any{"kind": "tool", "tool": "keeper_restart"}, "does not serve"}, - {map[string]any{"kind": "tool", "tool": "keeper_health", "interval": "2s"}, "no more often"}, - {map[string]any{"kind": "exec", "command": "true"}, "only a container"}, - {map[string]any{"kind": "tool", "tool": "keeper_health", "port": 80}, "names a port"}, - {"tool", "is an object"}, - } { - _, err := ParseManifest(ownAccountManifest(t, true, c.health)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%v: %v, want it refused saying %q", c.health, err, c.says) - } - } -} - // The runtime-health is composed onto .own-runtime, in the words the node-engine takes; to an engine // that does not read health it is not sent, as for every resource. func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) { diff --git a/go/catalogue/runtime_test.go b/go/catalogue/runtime_test.go index edc3a41..ee4c3d8 100644 --- a/go/catalogue/runtime_test.go +++ b/go/catalogue/runtime_test.go @@ -45,25 +45,6 @@ func theRuntime(t *testing.T) Manifest { return resolved } -func TestABuildsBundlesAreCarriedOnTheResolvedManifest(t *testing.T) { - m := aToolsModule(t, "nftables", "tools/index.js") - if len(m.Bundles) != 1 { - t.Fatalf("the resolved manifest carries %d bundle(s), not the one the build made", len(m.Bundles)) - } - b := m.Bundles[0] - if b.Name != "tools" || b.Digest != bundleDigest || b.Language != "typescript" || - b.Source != ArtifactStoreScheme+"nftables/tools/blobs/"+bundleDigest || - len(b.Entrypoints) != 1 || b.Entrypoints[0] != "tools/index.js" { - t.Errorf("the bundle is carried as %+v", b) - } - // A repository manifest may not write what the build derives. - raw := `{"module":"x","version":"1","build":{"artifacts":[{"name":"t","kind":"bundle","language":"typescript"}]},` + - `"bundles":[{"name":"t","source":"s","digest":"` + bundleDigest + `"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "bundles") { - t.Errorf("a manifest stating its build's output by hand was accepted: %v", err) - } -} - func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) { store := Rendering{ArtifactStore: "anchor.internal:5101", Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} @@ -212,131 +193,6 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) { }) } -// novox/hq ADR 0192: a tools bundle says what it is given; the composer resolves it per machine as -// a container's environment, hands it to the runtime as the module's words, and makes what the -// words name readable by the account the runtime runs as. -func TestABundleIsGivenItsWordsResolvedForThisMachine(t *testing.T) { - with := Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{ - RuntimeModule: {"broker": "sealed-credential"}, - "dash": {"token": "sealed-token"}, - }} - dash := Manifest{Module: "dash", Version: "1", Tools: []string{"status"}, - Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp"}}, - OwnSecrets: OwnSecrets{"token": {Path: "${dir:mesh-state}/token"}}, - Resources: []map[string]any{ - {"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"}, - {"id": "config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", "content": "{}\n"}, - {"id": "unrelated", "type": "file", "path": "/etc/dash.conf", "content": "x\n"}, - }, - Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript", - Entrypoints: []string{"tools/index.js"}, - Env: map[string]string{ - "DASH_CONFIG_FILE": "${dir:mesh-state}/config.json", - "DASH_TOKEN_FILE": "${dir:mesh-state}/token", - "DASH_URL": "http://127.0.0.1:${port:3000}", - "DASH_ADMIN": "mesh-admin", - }}}}} - if problems := dash.Build.problems(dash.Module); len(problems) > 0 { - t.Fatalf("a bundle's words written with ${dir:…} and ${port:…} were refused: %v", problems) - } - dash, err := dash.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, - Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) - if err != nil { - t.Fatal(err) - } - other := aToolsModule(t, "nftables", "tools/index.js") - - out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{dash, other, theRuntime(t)}}.Declaration(with) - if err != nil { - t.Fatal(err) - } - dir := fileNamed(out, "dash.mesh-state") - if dir == nil { - t.Fatalf("no directory: %v", ids(out)) - } - at := fmt.Sprint(dir["path"]) - process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) - env := process["env"].(map[string]string) - var given map[string]map[string]string - if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil { - t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv]) - } - want := map[string]string{ - "DASH_CONFIG_FILE": at + "/config.json", - "DASH_TOKEN_FILE": at + "/token", - "DASH_URL": "http://127.0.0.1:3000", - "DASH_ADMIN": "mesh-admin", - } - if fmt.Sprint(given["dash"]) != fmt.Sprint(want) { - t.Errorf("dash is given %v, want %v", given["dash"], want) - } - if _, has := given["nftables"]; has { - t.Errorf("a module that declares no words was given some: %v", given) - } - // What the words name is the account's to read; nothing else of the module's is touched. - for _, id := range []string{"dash.mesh-state", "dash.config", "dash." + NeedID("token")} { - if r := fileNamed(out, id); r == nil || r["owner"] != "ops" { - t.Errorf("%s is not the account's to read: %v", id, r) - } - } - if r := fileNamed(out, "dash.unrelated"); r == nil || r["owner"] != nil { - t.Errorf("a file no word names was given an owner: %v", r) - } - // A file a word names restarts the runtime when it changes, as it restarted the tool container. - restarts := fmt.Sprint(process["restart-on"]) - for _, want := range []string{"dash.config", "dash." + NeedID("token")} { - if !strings.Contains(restarts, want) { - t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts) - } - } - if strings.Contains(restarts, "dash.unrelated") || strings.Contains(restarts, "dash.mesh-state") { - t.Errorf("the runtime restarts for something no word names as a file: %s", restarts) - } - - t.Run("on a machine with no account the runtime is root and nothing is re-owned", func(t *testing.T) { - out, err := Resolution{Node: "anchor", Modules: []Manifest{dash, theRuntime(t)}}.Declaration(with) - if err != nil { - t.Fatal(err) - } - if r := fileNamed(out, "dash.config"); r["owner"] != nil { - t.Errorf("re-owned with no account: %v", r) - } - }) - - t.Run("a change to a module's words changes the runtime's process", func(t *testing.T) { - changed := dash - changed.Bundles = append([]Bundle(nil), dash.Bundles...) - changed.Bundles[0].Env = map[string]string{"DASH_ADMIN": "somebody-else"} - out2, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{changed, theRuntime(t)}}.Declaration(with) - if err != nil { - t.Fatal(err) - } - if fmt.Sprint(fileNamed(out2, RuntimeModule+"."+RuntimeProcessID())["env"]) == fmt.Sprint(env) { - t.Error("the runtime's process is the same after a module's words changed, so it would not restart") - } - }) -} - -func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) { - m := Manifest{Module: "dash", Version: "1", Build: &Build{Artifacts: []Artifact{ - {Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}, - Env: map[string]string{"DASH_TOKEN": "${secret:token}", RuntimeBrokerFile: "/x", "DASH_PEER": "${bound:db:url}"}}, - {Name: "runtime", Kind: ArtifactImage, From: "Dockerfile", Env: map[string]string{"X": "y"}}, - }}} - said := strings.Join(m.Build.problems(m.Module), "\n") - for _, want := range []string{ - `"tools" gives DASH_TOKEN the value "${secret:token}"`, - `"tools" gives DASH_PEER the value "${bound:db:url}"`, - `"tools" gives itself ` + RuntimeBrokerFile, - `"runtime" is a "image" and says what it is given`, - } { - if !strings.Contains(said, want) { - t.Errorf("not refused: %s\nsaid:\n%s", want, said) - } - } -} - // novox/hq ADR 0193: the runtime is told the launcher a build wrote, and the entrypoint itself for a // build from before launchers — so the move needs no flag day. func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) { @@ -390,74 +246,6 @@ func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) { } } -// novox/hq 04-ISSUES/216: a bundle nothing loads, runs or unpacks is refused at registration; saying -// `loads`, listing `tools`, or a resource naming it admits it. -func TestABundleNothingDeliversIsRefused(t *testing.T) { - base := func() Manifest { - return Manifest{Module: "baserow", Version: "1", Build: &Build{Artifacts: []Artifact{ - {Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}} - } - if p := undeliveredBundles(base()); len(p) != 1 || !strings.Contains(p[0], "never reach a machine") { - t.Fatalf("a bundle nothing delivers was admitted: %v", p) - } - loads := base() - loads.Build.Artifacts[0].Loads = []string{"tools/index.js"} - tools := base() - tools.Tools = []string{"baserow_list_rows"} - run := base() - run.Resources = []map[string]any{{"id": "daemon", "type": "process", "artifact": "tools", "run": []any{"node", "tools/index.js"}}} - runtime := base() - runtime.Module = RuntimeModule - for name, m := range map[string]Manifest{"loads": loads, "tools": tools, "a process": run, "the runtime": runtime} { - if p := undeliveredBundles(m); len(p) != 0 { - t.Errorf("a bundle delivered by %s was refused: %v", name, p) - } - } -} - -// novox/hq ADR 0193: a Go tools bundle is served — its binary is what the runtime starts, delivered -// like any tools bundle, named to the runtime where a TypeScript bundle names its launcher. -func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) { - with := Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} - lamp := Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, - Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go", - System: "arch", From: "cmd/lamp-tools"}}}} - if p := lamp.Build.problems("lamp"); len(p) != 0 { - t.Fatalf("a Go tools bundle was refused: %v", p) - } - lamp, err := lamp.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, - Reference: ArtifactStoreScheme + "lamp/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) - if err != nil { - t.Fatal(err) - } - if fmt.Sprint(lamp.Bundles[0].Loads) != "[lamp-tools]" { - t.Fatalf("the runtime loads %v from a Go bundle, want its binary", lamp.Bundles[0].Loads) - } - out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{lamp, theRuntime(t)}}.Declaration(with) - if err != nil { - t.Fatal(err) - } - if fileNamed(out, "lamp."+BundleID("tools")) == nil { - t.Errorf("the Go bundle is not delivered: %v", ids(out)) - } - env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string) - if env[RuntimeToolModules] != "lamp="+BundlePath("lamp", "tools")+"/lamp-tools" { - t.Errorf("the runtime is told %q, want the binary", env[RuntimeToolModules]) - } - - // An artifact may say it explicitly; naming anything but the binary is refused. - said := Manifest{Module: "lamp", Version: "1", Build: &Build{Artifacts: []Artifact{{Name: "tools", - Kind: ArtifactBundle, Language: "go", System: "arch", Binary: "lamp", Loads: []string{"lamp"}}}}} - if p := said.Build.problems("lamp"); len(p) != 0 { - t.Errorf("loads naming the binary was refused: %v", p) - } - said.Build.Artifacts[0].Loads = []string{"tools/index.js"} - if p := said.Build.problems("lamp"); len(p) == 0 { - t.Error("a Go bundle loading a file it does not contain was admitted") - } -} - // novox/hq ADR 0259 §8: a module of its own account is served by a runtime of its own — the machine's // runtime program, as that account, on that module's own credential — and never by the machine's runtime, // which runs as the operator's account and is given none of its words. diff --git a/go/catalogue/schedule_test.go b/go/catalogue/schedule_test.go index 289fb23..7b1f6e2 100644 --- a/go/catalogue/schedule_test.go +++ b/go/catalogue/schedule_test.go @@ -35,55 +35,3 @@ func TestAScheduledContainerRendersWithTheCronField(t *testing.T) { t.Errorf("schedule did not reach the host declaration: %+v", out[sync]) } } - -func TestAMalformedScheduleIsRefused(t *testing.T) { - digest := "@sha256:" + strings.Repeat("a", 64) - bad := []byte(`{"module":"m","resources":[ - {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"every night"} - ]}`) - if _, err := ParseManifest(bad); err == nil { - t.Error("a malformed schedule was accepted") - } else if !strings.Contains(err.Error(), "cron") { - t.Errorf("refused for the wrong reason: %v", err) - } - - // A schedule out of range is refused for the same reason, near its author. - outOfRange := []byte(`{"module":"m","resources":[ - {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"0 25 * * *"} - ]}`) - if _, err := ParseManifest(outOfRange); err == nil { - t.Error("a schedule with an hour out of range was accepted") - } - - good := []byte(`{"module":"m","resources":[ - {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"*/15 * * * *"} - ]}`) - if _, err := ParseManifest(good); err != nil { - t.Errorf("a valid scheduled container was refused: %v", err) - } -} - -func TestAScheduleThatIsNotAStringIsRefused(t *testing.T) { - digest := "@sha256:" + strings.Repeat("a", 64) - bad := []byte(`{"module":"m","resources":[ - {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":true} - ]}`) - if _, err := ParseManifest(bad); err == nil { - t.Error("a schedule that is not a string was accepted") - } else if !strings.Contains(err.Error(), "schedule") { - t.Errorf("refused for the wrong reason: %v", err) - } -} - -func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) { - // A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053). - digest := "@sha256:" + strings.Repeat("a", 64) - bad := []byte(`{"module":"m","resources":[ - {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","run-once":true,"schedule":"0 3 * * *"} - ]}`) - if _, err := ParseManifest(bad); err == nil { - t.Error("a container that was both run-once and scheduled was accepted") - } else if !strings.Contains(err.Error(), "both run-once and schedule") { - t.Errorf("refused for the wrong reason: %v", err) - } -} diff --git a/go/catalogue/seat_contributions.go b/go/catalogue/seat_contributions.go index 2903484..192b7c0 100644 --- a/go/catalogue/seat_contributions.go +++ b/go/catalogue/seat_contributions.go @@ -71,65 +71,6 @@ func kindsOf(s Seat) string { return "it receives " + strings.Join(kinds, ", ") } -// seatContributionProblems is what is wrong with this module's contributions, from the manifest -// alone (novox/hq ADR 0212 §2). -func (m Manifest) seatContributionProblems() []string { - var problems []string - for i, c := range m.Contributions { - s, r, ok := receivable(c.Seat, c.Kind) - // A directory a contribution names must be one of this module's own, here rather than on the - // machine — where a `${dir:x}` nobody declared would reach the holder as the literal text. - if ok && r.Dirs { - declared := map[string]string{} - for _, res := range m.Resources { - if fmt.Sprint(res["type"]) == "directory" { - declared[fmt.Sprint(res["id"])] = "" - } - } - if _, err := dirFill(c.Content, declared, m.Module); err != nil { - problems = append(problems, fmt.Sprintf("%s's contribution %d: %v", m.Module, i+1, err)) - } - } - switch { - case s.Name == "": - problems = append(problems, fmt.Sprintf( - "%s's contribution %d is to the seat %q, which the mesh does not define", m.Module, i+1, c.Seat)) - case !ok: - problems = append(problems, fmt.Sprintf( - "%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)", - m.Module, i+1, s.Name, c.Kind, kindsOf(s))) - } - // Only on a kind the seat offers (novox/hq ADR 0255): a piece shown where the hardware is. On a - // kind a holder depends on — what a backup keeps, a key's trigger — a machine missing the - // capability would lose the piece without a word. - if c.IfCapability != "" { - switch { - case !capabilityName.MatchString(c.IfCapability): - problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which is not a "+ - "capability's name", m.Module, i+1, c.IfCapability)) - case !oneOf(KnownCapabilities, c.IfCapability): - problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which no machine "+ - "reports; the node-engine detects %s", m.Module, i+1, c.IfCapability, strings.Join(KnownCapabilities, ", "))) - case ok && !r.Offered: - problems = append(problems, fmt.Sprintf("%s's contribution %d to %s (%s) is if-capability %s; only a "+ - "kind the seat offers may be left out where a machine lacks something (novox/hq ADR 0255)", - m.Module, i+1, s.Name, c.Kind, c.IfCapability)) - } - } - switch { - case ok && r.Shape != nil: - problems = append(problems, shapedProblems(m.Module, i, r, c)...) - problems = append(problems, ownStateProblems(m, i, r, c)...) - case len(c.Data) > 0: - problems = append(problems, fmt.Sprintf("%s's contribution %d has data; %s receives %s as text, "+ - "given as `content`", m.Module, i+1, s.Name, c.Kind)) - case strings.TrimSpace(c.Content) == "": - problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1)) - } - } - return problems -} - // seatPlaceholderProblems is what is wrong with one resource's ${contribution:…}: placed only in a // file's content, naming a seat and a kind it receives, and only by a module that claims that seat // — another would be a second writer of a file there is one of (novox/hq ADR 0212 §3). diff --git a/go/catalogue/seat_contributions_test.go b/go/catalogue/seat_contributions_test.go index ad6d31e..7af7046 100644 --- a/go/catalogue/seat_contributions_test.go +++ b/go/catalogue/seat_contributions_test.go @@ -64,32 +64,6 @@ func TestNoContributionPlacesNothing(t *testing.T) { } } -func TestAContributionToASeatThatDoesNotReceiveItIsRefused(t *testing.T) { - cases := map[string]string{ - `{"seat":"node-hotkeys","kind":"config","content":"x"}`: "it receives trigger", - `{"seat":"node-nothing","kind":"trigger","content":"x"}`: "the mesh does not define", - `{"seat":"node-hotkeys","kind":"trigger","content":" "}`: "has no content", - `{"seat":"node-display-session","kind":"trigger","content":"x"}`: "it receives config", - } - for c, want := range cases { - raw := `{"module":"laptop","contributions":[` + c + `]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) - } - } -} - -func TestAContributionPlaceholderOutsideTheHolderIsRefused(t *testing.T) { - raw := `{"module":"laptop","resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:trigger}"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "laptop does not claim node-hotkeys") { - t.Errorf("a placeholder outside the holder was accepted: %v", err) - } - raw = `{"module":"triggerhappy","claims":[{"name":"node-hotkeys"}],"resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:keys}"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "it receives trigger") { - t.Errorf("a placeholder for a kind the seat does not receive was accepted: %v", err) - } -} - func TestAContributionDependsOnItsSeat(t *testing.T) { m := Manifest{Module: "laptop", Contributions: []SeatContribution{ {Seat: HotkeysSeat, Kind: "trigger", Content: "x"}, diff --git a/go/catalogue/seat_data.go b/go/catalogue/seat_data.go index ab3bbcf..8397762 100644 --- a/go/catalogue/seat_data.go +++ b/go/catalogue/seat_data.go @@ -59,9 +59,6 @@ type PlaceField struct { // OrderDefault is a piece's order when it gives none: the middle of 0–99. const OrderDefault = 50 -// capabilityName is the shape of a capability's name, as a node's profile reports it. -var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) - // KnownCapabilities is every capability the node-engine detects (mesh-host internal/profile), the // names an `if-capability` may give (novox/hq ADR 0255). A name nothing detects would leave its // contribution out on every machine, silently, so it is refused. A detector added there is added here. @@ -83,67 +80,6 @@ func quote(v any) (string, error) { return strings.ReplaceAll(string(b), "\x7f", `\u007f`), err } -// shapedProblems is what is wrong with one contribution of a kind received as data. -func shapedProblems(module string, i int, r Receivable, c SeatContribution) []string { - var problems []string - at := fmt.Sprintf("%s's contribution %d to %s (%s)", module, i+1, c.Seat, c.Kind) - if strings.TrimSpace(c.Content) != "" { - problems = append(problems, at+" has content; this kind is data, given as `data` (novox/hq ADR 0255)") - } - if len(c.Data) == 0 { - return append(problems, at+" has no data") - } - for _, p := range r.Shape.Places { - v, _ := c.Data[p.Field].(string) - if !oneOf(p.Values, v) { - problems = append(problems, fmt.Sprintf("%s says %s %q; it is one of %s", - at, p.Field, fmt.Sprint(c.Data[p.Field]), strings.Join(p.Values, ", "))) - } - } - if r.Shape.Order != "" { - if v, has := c.Data[r.Shape.Order]; has { - if _, ok := wholeIn(v, 0, 99); !ok { - problems = append(problems, fmt.Sprintf("%s says %s %v; it is a whole number from 0 to 99", - at, r.Shape.Order, v)) - } - } - } - if r.Shape.Check != nil { - for _, p := range r.Shape.Check(c.Data) { - problems = append(problems, at+": "+p) - } - } - return problems -} - -// ownStateProblems is a data contribution showing state its module does not keep (novox/hq ADR 0255): -// a module offers the holder its own state and nobody else's, so what it publishes stays its own. -func ownStateProblems(m Manifest, i int, r Receivable, c SeatContribution) []string { - if r.Shape == nil || r.Shape.Reads == nil { - return nil - } - keeps := map[string]bool{} - for _, s := range m.State { - keeps[s.Name] = true - } - var problems []string - for _, sr := range r.Shape.Reads(c.Data) { - module, local, err := ReadState(sr.Read) - switch { - case err != nil: - problems = append(problems, fmt.Sprintf("%s's contribution %d shows state %v", m.Module, i+1, err)) - case module != m.Module || !keeps[local]: - problems = append(problems, fmt.Sprintf("%s's contribution %d shows the state %s, which %s does not keep; a "+ - "module offers its own state only (novox/hq ADR 0260)", m.Module, i+1, sr.Read, m.Module)) - } - if sr.Key != "" && !stateKeyName.MatchString(sr.Key) { - problems = append(problems, fmt.Sprintf("%s's contribution %d shows the key %q; a key is lower-case letters, "+ - "digits and hyphens", m.Module, i+1, sr.Key)) - } - } - return problems -} - // StateRead is one key of another module's state that a holder reads: Read is `.`, // and Key the one key, empty for the holder's own machine until ReadsGranted names it. type StateRead struct { @@ -429,119 +365,3 @@ func describePiece(data map[string]any) string { func capable(c SeatContribution, caps map[string]bool) bool { return c.IfCapability == "" || caps[c.IfCapability] } - -// placementProblems is what is wrong with how a holder places the kinds its seats receive as data: -// once it places one at all, it needs a template that renders every example of the shape, and every combination of the placing -// fields placed exactly once across its files — or something contributed would be dropped, or written -// twice (novox/hq ADR 0255). -func (m Manifest) placementProblems() []string { - var problems []string - for _, c := range m.Claims { - s, known := SeatNamed(c.Name) - if !known { - continue - } - for _, r := range s.Receives { - // A holder that places none of the kind is older than it (novox/hq ADR 0255): refusing it - // would refuse every bar registered before the kind existed, and the controller that - // defines the kind ships first. Its contributions wait, unplaced, until it places them. - if r.Shape == nil || !placesKind(m, s, r.Kind) { - continue - } - t, err := holderTemplate(m, s, r.Kind) - if err != nil { - problems = append(problems, err.Error()) - continue - } - for _, ex := range r.Shape.Examples { - if out, err := renderPiece(t, "example", "machine", ex); err != nil || strings.TrimSpace(out) == "" { - problems = append(problems, fmt.Sprintf("%s's template for %s:%s renders nothing for %v: %v", - m.Module, s.Name, r.Kind, ex, err)) - } - } - placed := map[string]int{} - for _, res := range m.Resources { - content, _ := res["content"].(string) - for _, f := range ofContribution.FindAllStringSubmatch(content, -1) { - seat, rest, _ := strings.Cut(f[1], ":") - kind, whereText, _ := strings.Cut(rest, ":") - if cs, ok := SeatNamed(seat); !ok || cs.Name != s.Name || kind != r.Kind { - continue - } - where, err := placeholderWhere(r, whereText) - if err != nil { - continue // said by seatPlaceholderProblems - } - for _, combo := range combinations(r.Shape.Places) { - if covers(where, combo) { - placed[comboKey(combo)]++ - } - } - } - } - for _, combo := range combinations(r.Shape.Places) { - switch n := placed[comboKey(combo)]; { - case n == 0: - problems = append(problems, fmt.Sprintf("%s never places %s:%s for %s; every combination of %s is "+ - "placed once, or a contribution to it is lost (novox/hq ADR 0255)", - m.Module, s.Name, r.Kind, comboKey(combo), placeFields(r))) - case n > 1: - problems = append(problems, fmt.Sprintf("%s places %s:%s for %s %d times; once, or a contribution "+ - "is written twice (novox/hq ADR 0255)", m.Module, s.Name, r.Kind, comboKey(combo), n)) - } - } - } - } - return problems -} - -// placesKind is whether any of a holder's files names the kind's placeholder. -func placesKind(m Manifest, s Seat, kind string) bool { - for _, res := range m.Resources { - content, _ := res["content"].(string) - for _, f := range ofContribution.FindAllStringSubmatch(content, -1) { - seat, rest, _ := strings.Cut(f[1], ":") - k, _, _ := strings.Cut(rest, ":") - if cs, ok := SeatNamed(seat); ok && cs.Name == s.Name && k == kind { - return true - } - } - } - return false -} - -// combinations is every assignment of a value to each placing field. -func combinations(places []PlaceField) []map[string]string { - out := []map[string]string{{}} - for _, p := range places { - var next []map[string]string - for _, partial := range out { - for _, v := range p.Values { - c := map[string]string{p.Field: v} - for k, w := range partial { - c[k] = w - } - next = append(next, c) - } - } - out = next - } - return out -} - -func covers(where, combo map[string]string) bool { - for field, value := range where { - if combo[field] != value { - return false - } - } - return true -} - -func comboKey(combo map[string]string) string { - var parts []string - for _, k := range sortedKeys(combo) { - parts = append(parts, k+"="+combo[k]) - } - return strings.Join(parts, ",") -} diff --git a/go/catalogue/seat_data_test.go b/go/catalogue/seat_data_test.go index d39c59e..02876c5 100644 --- a/go/catalogue/seat_data_test.go +++ b/go/catalogue/seat_data_test.go @@ -102,27 +102,6 @@ func TestAContributionIfACapabilityIsPlacedOnlyWhereTheMachineReportsIt(t *testi } } -func TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly(t *testing.T) { - cases := map[string]string{ - // A name nothing detects would leave the piece out on every machine. - `{"seat":"node-bar","kind":"block","if-capability":"batery","data":{"bar":"top","place":"status","shows":"battery"}}`: "which no machine reports", - // A kind the holder depends on is never left out for want of something. - `{"seat":"node-hotkeys","kind":"trigger","if-capability":"battery","content":"KEY_BATTERY 1 x"}`: "only a kind the seat offers", - `{"seat":"node-backup","kind":"backup","if-capability":"seat","content":"path /x"}`: "only a kind the seat offers", - } - for c, want := range cases { - raw := `{"module":"power","contributions":[` + c + `]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) - } - } - for _, name := range []string{"battery", "power-meter", "seat"} { - if !oneOf(KnownCapabilities, name) { - t.Errorf("%s is not a known capability", name) - } - } -} - func TestResolveCarriesTheMachinesCapabilities(t *testing.T) { node := workstation() node.Capabilities["battery"] = true @@ -157,52 +136,6 @@ func TestAPieceTheHolderCannotRenderIsLeftOutAndNamedNotTheMachine(t *testing.T) } } -// A moved bar is rendered by the provisioner's client, not by the controller, so the rule that its template renders -// every example of the shape is no longer judged here (issue 915, which also covers placementProblems not reading the -// collected form). What the controller still owns is that the bar's files place the blocks at all: a moved bar's -// carry the collected placement, and one that stopped placing them fails here rather than being skipped (issue 432). -func TestTheCataloguesBarPlacesBlocksAndItsShapeCoversEveryShow(t *testing.T) { - holder := catalogueManifest(t, "i3status-rust") - s, _ := SeatNamed(BarSeat) - if holder.Moved { - placed := "${collected:contribution:" + BarSeat + ":" + BarKindBlock + ":" - found := false - for _, res := range holder.Resources { - if content, _ := res["content"].(string); strings.Contains(content, placed) { - found = true - } - } - if !found { - t.Fatalf("the catalogue's moved bar places no blocks: none of its files carries %s…}", placed) - } - } else { - if !placesKind(holder, s, BarKindBlock) { - // It places them since the catalogue's bar took the seat; a skip here hid a bar that stopped - // (novox/hq issue 432). - t.Fatal("the catalogue's bar places no blocks, so none of the shape's examples would render") - } - tmpl, err := holderTemplate(holder, s, BarKindBlock) - if err != nil { - t.Fatal(err) - } - for _, ex := range s.Receives[0].Shape.Examples { - out, err := renderPiece(tmpl, "example", "a-machine", ex) - if err != nil || !strings.Contains(out, "[[block]]") { - t.Errorf("the catalogue's bar renders %v as %q: %v", ex, out, err) - } - } - } - for shows := range barShows { - covered := false - for _, ex := range s.Receives[0].Shape.Examples { - covered = covered || ex["shows"] == shows - } - if !covered { - t.Errorf("the shape has no example that shows %s, so no holder is made to render it", shows) - } - } -} - func TestQuoteIsATomlBasicStringOfTheSameText(t *testing.T) { for _, in := range []string{`plain`, `say "hi" \ there`, "tab\tnew\nline", "del\x7fend", "nul\x00 esc\x1b", "<&> é ☃"} { q, err := quote(in) @@ -276,75 +209,6 @@ func tomlBasicString(s string) (string, error) { return b.String(), nil } -func TestABlockOutsideTheShapeIsRefused(t *testing.T) { - cases := map[string]string{ - `{"seat":"node-bar","kind":"block","content":"[[block]]"}`: "has no data", - `{"seat":"node-bar","kind":"block","content":"x","data":{"bar":"top","place":"status","shows":"battery"}}`: "has content", - `{"seat":"node-hotkeys","kind":"trigger","data":{"a":"b"}}`: "receives trigger as text", - `{"seat":"node-bar","kind":"block","data":{"bar":"left","place":"status","shows":"battery"}}`: `bar "left"`, - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"middle","shows":"battery"}}`: `place "middle"`, - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"gpu"}}`: "shows one of battery, command", - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","colour":"red"}}`: `no field "colour"`, - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","order":100}}`: "whole number from 0 to 99", - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command"}}`: "needs the option command", - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","options":{"x":"y"}}}`: `no option "x"`, - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command","options":{"command":"c","interval":0}}}`: "seconds", - `{"seat":"node-bar","kind":"block","if-capability":"Has Battery","data":{"bar":"top","place":"status","shows":"battery"}}`: "not a capability", - } - for c, want := range cases { - raw := `{"module":"power","contributions":[` + c + `]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) - } - } - ok := `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":"battery",` + - `"data":{"bar":"bottom","place":"status","order":10,"shows":"command","options":{"command":"c","interval":5}}}]}` - if _, err := ParseManifest([]byte(ok)); err != nil { - t.Errorf("a block in the shape was refused: %v", err) - } -} - -func TestAHolderPlacesEveryCombinationOnceWithATemplateThatRendersEveryExample(t *testing.T) { - holder := func(template string, contents ...string) string { - var res []string - for i, c := range contents { - b, _ := json.Marshal(c) - res = append(res, `{"id":"f`+string(rune('a'+i))+`","type":"file","path":"/f`+string(rune('a'+i))+`","content":`+string(b)+`}`) - } - tb, _ := json.Marshal(template) - renders := "" - if template != "" { - renders = `,"renders":{"block":` + string(tb) + `}` - } - return `{"module":"a-bar","claims":[{"name":"node-bar"` + renders + `}],"resources":[` + strings.Join(res, ",") + `]}` - } - all := "${contribution:node-bar:block:bar=bottom,place=resources}${contribution:node-bar:block:bar=bottom,place=status}" + - "${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}" - cases := map[string]string{ - holder(barTemplate, "${contribution:node-bar:block:bar=bottom}"): "never places node-bar:block for bar=top,place=resources", - holder(barTemplate, all, "${contribution:node-bar:block:place=status}"): "for bar=bottom,place=status 2 times", - holder("", all): "gives no template", - holder(`{{if eq .shows "battery"}}b{{end}}`, all): "renders nothing", - holder(`{{.nothing}}`, all): "renders nothing", - holder(`{{if}`, all): "does not parse", - holder(barTemplate, "${contribution:node-bar:block:bar=left}"+all): "bar is one of bottom, top", - holder(barTemplate, "${contribution:node-bar:block:colour=red}"+all): "placed by bar, place", - holder(barTemplate, "${contribution:node-hotkeys:trigger:bar=top}"): "does not claim node-hotkeys", - } - for raw, want := range cases { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s:\naccepted, or refused without %q: %v", raw, want, err) - } - } - for _, raw := range []string{holder(barTemplate, all), holder(barTemplate, "${contribution:node-bar:block}"), - // A holder that places no block at all is older than the kind, and is not refused for it. - holder("", "no blocks here")} { - if _, err := ParseManifest([]byte(raw)); err != nil { - t.Errorf("%s: refused: %v", raw, err) - } - } -} - func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) { power := Manifest{Module: "power", Contributions: []SeatContribution{ block(`{"bar":"bottom","place":"status","shows":"battery"}`), @@ -406,26 +270,8 @@ func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *tes } } -func TestAModuleOffersItsOwnStateOnly(t *testing.T) { - block := `{"seat":"node-bar","kind":"block","data":{"bar":"bottom","place":"status","shows":"state","options":{"state":"%s"}}}` - cases := map[string]string{ - `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "other.draw") + `]}`: "which power does not keep", - `{"module":"power","contributions":[` + fmt.Sprintf(block, "power.draw") + `]}`: "which power does not keep", - `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "draw") + `]}`: "does not name a module", - } - for raw, want := range cases { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: accepted, or refused without %q: %v", raw, want, err) - } - } - ok := `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "power.draw") + `]}` - if _, err := ParseManifest([]byte(ok)); err != nil { - t.Errorf("its own state was refused: %v", err) - } -} - func TestPerMachineStateIsDeclaredAndKeptInItsLongForm(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"power","state":[{"name":"draw","ttl-seconds":30,"per-machine":true}]}`)) + m, err := decodeForTest([]byte(`{"module":"power","state":[{"name":"draw","ttl-seconds":30,"per-machine":true}]}`)) if err != nil || len(m.State) != 1 || !m.State[0].PerMachine { t.Fatalf("%+v %v", m.State, err) } diff --git a/go/catalogue/seat_invokes.go b/go/catalogue/seat_invokes.go index d130b3d..6e5d59d 100644 --- a/go/catalogue/seat_invokes.go +++ b/go/catalogue/seat_invokes.go @@ -1,8 +1,6 @@ package catalogue import ( - "fmt" - "slices" "strings" ) @@ -26,68 +24,3 @@ func SeatInvoke(invoke string) (seat, verb string, ok bool) { seat, verb, ok = strings.Cut(rest, ".") return seat, verb, ok && seat != "" && verb != "" } - -// seatInvokeProblems are the `seat:.` a module or one of its processes invokes that nothing answers: a seat -// no module declares and the mesh does not define, a verb the seat does not promise, or a seat with no holder (a -// seat a module declares and no module claims; a mesh seat's holder, the mesh itself or a module of any repository, -// is not judged). -func seatInvokeProblems(shelf Shelf, declared map[string]SeatDeclaration) []string { - claimed := map[string]bool{} - for _, m := range shelf { - for _, c := range m.Claims { - claimed[c.Name] = true - if s, ok := SeatNamed(c.Name); ok { - claimed[s.Name] = true - } - } - } - var problems []string - for _, module := range shelfOrder(shelf) { - m := shelf[module] - for _, invoke := range invokesOf(m) { - seat, verb, ok := SeatInvoke(invoke) - if !ok { - continue - } - var verbs []Verb - held := claimed[seat] - if s, isMesh := SeatNamed(seat); isMesh { - // A mesh seat is held by the mesh itself or by a module of any repository: one repository's catalogue - // cannot say it has no holder, so only its verbs are judged (the lead, 2026-10-30: mesh-control's check - // runs over the captured mesh-catalog alone, whose mesh-self-check calls the watchman's seat). - verbs, held = s.Serves, true - } else if d, isModule := declared[seat]; isModule { - verbs = d.Serves - } else { - problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+UndeclaredSeat, module, invoke, seat)) - continue - } - if !slices.ContainsFunc(verbs, func(v Verb) bool { return v.Name == verb }) { - problems = append(problems, fmt.Sprintf("%s invokes %s, and the seat %s serves no verb %q", module, invoke, seat, verb)) - continue - } - if !held { - problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+UnheldSeat, module, invoke, seat)) - } - } - } - return problems -} - -// invokesOf is everything a module says it invokes: its own and each process's bus, once each. -func invokesOf(m Manifest) []string { - out := append([]string(nil), m.Invokes...) - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "process" { - continue - } - if _, present := r["bus"]; !present { - continue - } - if b, _, err := processBusOf(r); err == nil { - out = append(out, b.Invokes...) - } - } - slices.Sort(out) - return slices.Compact(out) -} diff --git a/go/catalogue/seat_paths.go b/go/catalogue/seat_paths.go index a2a13af..da4dacd 100644 --- a/go/catalogue/seat_paths.go +++ b/go/catalogue/seat_paths.go @@ -32,89 +32,6 @@ type SeatPath struct { StepsBackFor string `json:"steps-back-for,omitempty"` } -// seatPathProblem is what is wrong with one path a seat names, judged alone: absolute, written literally -// with no placeholder, and clean, so it compares with what a module declares byte for byte. -func seatPathProblem(seat, scope, p string) string { - switch { - case scope != ScopeNode: - return fmt.Sprintf("the seat %s names the path %q and is a %s seat; only a node seat names paths, "+ - "because a path is a machine's (novox/hq ADR 0287)", seat, p, scope) - case strings.Contains(p, "${"): - return fmt.Sprintf("the seat %s names the path %q, which has a placeholder; a seat's path is written "+ - "literally (novox/hq ADR 0287)", seat, p) - case !strings.HasPrefix(p, "/") || path.Clean(p) != p: - return fmt.Sprintf("the seat %s names the path %q, which is not a clean absolute path (novox/hq ADR 0287)", - seat, p) - case path.Dir(p) == "/": - // The root or a top-level directory is every role's, never one role's own (ADR 0287). - return fmt.Sprintf("the seat %s names the path %q, the root or a top-level directory, which no one "+ - "role owns (novox/hq ADR 0287)", seat, p) - } - return "" -} - -// SeatPathProblems is what is wrong with the paths a set of the mesh's own seats names (novox/hq ADR -// 0287): only a node seat names one, each absolute and literal; every path has exactly one seat naming it -// without stepping back; and a seat steps back only for another node seat naming the same path without -// stepping back, so the stepping back cannot cycle. -func SeatPathProblems(set []Seat) []string { - var problems []string - owners := map[string][]string{} // path → the seats naming it without stepping back - stepping := map[string][]SeatPath{} // seat → the paths it names stepping back - byName := map[string]Seat{} - for _, s := range set { - byName[s.Name] = s - for _, p := range s.Paths { - if why := seatPathProblem(s.Name, s.Scope, p.Path); why != "" { - problems = append(problems, why) - } - if p.StepsBackFor == "" { - owners[p.Path] = append(owners[p.Path], s.Name) - } else { - stepping[s.Name] = append(stepping[s.Name], p) - } - } - } - for _, s := range set { - for _, p := range stepping[s.Name] { - other, known := byName[p.StepsBackFor] - switch { - case !known || other.Scope != ScopeNode: - problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %q, which is not a "+ - "node seat of the mesh's (novox/hq ADR 0287)", s.Name, p.Path, p.StepsBackFor)) - case !namesWithoutSteppingBack(other, p.Path): - problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %s, which does not "+ - "own that path itself; a seat steps back only for the one that owns it (novox/hq ADR 0287)", - s.Name, p.Path, other.Name)) - } - } - } - for _, p := range sortedKeys(owners) { - if len(owners[p]) > 1 { - problems = append(problems, fmt.Sprintf("the path %q is named by %s, each without stepping back; "+ - "no two seats name one path (novox/hq ADR 0287)", p, strings.Join(owners[p], " and "))) - } - } - for _, s := range set { - for _, p := range stepping[s.Name] { - if len(owners[p.Path]) == 0 { - problems = append(problems, fmt.Sprintf("the path %q has no seat naming it without stepping back "+ - "(novox/hq ADR 0287)", p.Path)) - } - } - } - return problems -} - -func namesWithoutSteppingBack(s Seat, p string) bool { - for _, sp := range s.Paths { - if sp.Path == p && sp.StepsBackFor == "" { - return true - } - } - return false -} - // declaredPaths is every machine path a module declares it owns: a resource's literal path, and a file // the mesh renders for it outside the operator's home. An access is not here — reading is not owning. // @@ -226,28 +143,6 @@ func unclaimedSeatPaths(m Manifest, named map[string][]string) []string { return problems } -// declaredSeatPathProblems is what one module's own seat declaration can be judged on alone: a path -// only on a node seat, absolute, literal and clean. -func declaredSeatPathProblems(m Manifest, s SeatDeclaration) []string { - var problems []string - for _, p := range s.Paths { - if why := seatPathProblem(s.Name, s.At(), p); why != "" { - problems = append(problems, m.Module+": "+why) - } - } - return problems -} - -// asSeatPaths is a module's own seat's paths in the shape the mesh's seats name theirs. A module's seat -// never steps back: only the mesh's own seats do, each by a record. -func asSeatPaths(paths []string) []SeatPath { - out := make([]SeatPath, 0, len(paths)) - for _, p := range paths { - out = append(out, SeatPath{Path: p}) - } - return out -} - // PathOwner is the module that owns one seat's path on one machine (novox/hq ADR 0287): the seat naming // the path, and that seat's holder there, with the stepping back applied. type PathOwner struct { diff --git a/go/catalogue/seat_paths_test.go b/go/catalogue/seat_paths_test.go index a6f5df5..44faa65 100644 --- a/go/catalogue/seat_paths_test.go +++ b/go/catalogue/seat_paths_test.go @@ -31,40 +31,6 @@ func TestTheSeatsThatNamePathsAreTheStartingSet(t *testing.T) { } } -// The compiled set keeps the rules: node seats only, absolute and literal, one seat per path without -// stepping back, and a stepping back that cannot cycle. -func TestTheCompiledSeatPathsKeepTheRules(t *testing.T) { - if p := SeatPathProblems(DefaultSeats()); len(p) != 0 { - t.Fatalf("the compiled set breaks ADR 0287: %v", p) - } - for _, c := range []struct { - why string - set []Seat - want string - }{ - {"a mesh seat naming a path", []Seat{{Name: "mesh-x", Scope: ScopeMesh, Paths: []SeatPath{{Path: "/etc/x"}}}}, - "only a node seat names paths"}, - {"a placeholder", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/${setting:x}"}}}}, - "placeholder"}, - {"a relative path", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "etc/x"}}}}, - "not a clean absolute path"}, - {"two owners", []Seat{ - {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}, - {Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}}, "no two seats name one path"}, - {"a cycle", []Seat{ - {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-b"}}}, - {Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-a"}}}}, - "does not own that path itself"}, - {"stepping back for nobody", []Seat{ - {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-z"}}}}, - "not a node seat"}, - } { - if got := strings.Join(SeatPathProblems(c.set), "; "); !strings.Contains(got, c.want) { - t.Errorf("%s: %q does not say %q", c.why, got, c.want) - } - } -} - // What the store's rows carry is never the paths: a row read back keeps the compiled ones. func TestTheSeatPathsSurviveTheStoresRows(t *testing.T) { before := seats @@ -90,112 +56,6 @@ func hostnameHolder(resources ...map[string]any) Manifest { Serves: []string{"entries", "add", "remove"}}}, Resources: resources} } -// A holder declares every path its seat names, as a resource or as a file the mesh renders for it, or -// CanHold refuses it naming the seat and each missing path — the judgement registration and handover share. -func TestAClaimWithoutTheSeatsPathsIsRefused(t *testing.T) { - seat := hostnameSeat(t) - hosts := map[string]any{"id": "hosts", "type": "file", "path": "/etc/hosts", "content": "x"} - name := map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"} - - if err := CanHold(hostnameHolder(hosts, name), seat); err != nil { - t.Errorf("a holder declaring both paths as resources was refused: %v", err) - } - rendered := hostnameHolder(hosts) - rendered.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Template: "{{.Node}}\n"}} - if err := CanHold(rendered, seat); err != nil { - t.Errorf("a holder declaring a path as a file the mesh renders was refused: %v", err) - } - err := CanHold(hostnameHolder(), seat) - if err == nil { - t.Fatal("a holder declaring neither path was accepted") - } - for _, want := range []string{"node-hostname", "/etc/hosts", "/etc/hostname", "ADR 0287"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not name %s: %v", want, err) - } - } - // A file under the operator's home is placed per account: it is not the machine's path. - home := hostnameHolder(hosts) - home.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Home: true, Template: "x"}} - if err := CanHold(home, seat); err == nil { - t.Error("a file in the operator's home counted as the machine's /etc/hostname") - } - // At registration, the same refusal. - if got := strings.Join(CatalogueProblems(Shelf{"names": hostnameHolder(hosts)}), "; "); !strings.Contains(got, - "does not declare /etc/hostname") { - t.Errorf("registration let a holder without /etc/hostname through: %q", got) - } -} - -// Only a claimant declares a seat's path: a module claiming no seat that names /etc/hosts and declaring -// it is refused at registration naming the seat; the uplink's holder and the resolver's both declaring -// the resolver file are each a claimant of a seat naming it. Reading a seat's path is access, and allowed. -func TestOnlyAClaimantDeclaresASeatsPath(t *testing.T) { - stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ - {"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}} - got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ") - if !strings.Contains(got, `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { - t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) - } - // However the path is spelled (novox/hq issue 412 review). - unclean := Manifest{Module: "unclean", Version: "1", Resources: []map[string]any{ - {"id": "hosts", "type": "file", "path": "/etc/../etc/hosts", "content": "10.0.0.1 x\n"}}} - if got := strings.Join(CatalogueProblems(Shelf{"unclean": unclean}), "; "); !strings.Contains(got, "node-hostname") { - t.Errorf("/etc/../etc/hosts from a module not claiming node-hostname was not refused: %q", got) - } - reader := Manifest{Module: "reader", Version: "1", Accesses: []Access{{ID: "hosts", Path: "/etc/hosts"}}} - if got := CatalogueProblems(Shelf{"reader": reader}); len(got) != 0 { - t.Errorf("reading /etc/hosts was refused: %v", got) - } - uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}, - Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}} - resolver := localResolver() - resolver.Claims[0].Serves = []string{"routes", "route", "unroute"} - if got := CatalogueProblems(Shelf{"uplink": uplink, resolver.Module: resolver}); len(got) != 0 { - t.Errorf("the uplink's holder and the resolver's declaring the resolver file were refused: %v", got) - } -} - -// A module's own seat names paths under the same rules: a node seat only, literal, and none another seat -// names — the second declaration loses. Its holder declares them, as a mesh seat's does. -func TestAModulesOwnSeatNamesPathsUnderTheSameRules(t *testing.T) { - meshScoped := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ - {Name: "a-seat", Paths: []string{"/etc/a"}}}} - if got := strings.Join(declaredSeatProblems(meshScoped), "; "); !strings.Contains(got, "only a node seat names paths") { - t.Errorf("a module's mesh seat naming a path was accepted: %q", got) - } - placeholder := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ - {Name: "a-seat", Scope: ScopeNode, Paths: []string{"${dir:state}/a"}}}} - if got := strings.Join(declaredSeatProblems(placeholder), "; "); !strings.Contains(got, "placeholder") { - t.Errorf("a module's seat naming a path with a placeholder was accepted: %q", got) - } - - taken := Manifest{Module: "b", Version: "1", DefinesSeats: []SeatDeclaration{ - {Name: "b-seat", Scope: ScopeNode, Paths: []string{"/etc/hosts"}}}} - if got := strings.Join(CatalogueProblems(Shelf{"b": taken}), "; "); !strings.Contains(got, - `b declares the seat b-seat naming the path "/etc/hosts", which node-hostname already names`) { - t.Errorf("a module's seat naming /etc/hosts was accepted: %q", got) - } - - own := Manifest{Module: "c", Version: "1", - DefinesSeats: []SeatDeclaration{{Name: "c-seat", Scope: ScopeNode, Paths: []string{"/etc/c.conf"}}}, - Claims: []Claim{{Name: "c-seat", Scope: ScopeNode}}} - if got := strings.Join(CatalogueProblems(Shelf{"c": own}), "; "); !strings.Contains(got, - "c claims c-seat but does not declare /etc/c.conf") { - t.Errorf("a holder of a module's seat without its path was accepted: %q", got) - } - own.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}} - if got := CatalogueProblems(Shelf{"c": own}); len(got) != 0 { - t.Errorf("a holder declaring its own seat's path was refused: %v", got) - } - other := Manifest{Module: "d", Version: "1", Resources: []map[string]any{ - {"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}} - if got := strings.Join(CatalogueProblems(Shelf{"c": own, "d": other}), "; "); !strings.Contains(got, - `d declares the path "/etc/c.conf", which the seat c-seat names`) { - t.Errorf("a module not claiming c-seat declared its path: %q", got) - } -} - type writesHosts struct{} func (writesHosts) Resources(string) ([]map[string]any, bool, error) { @@ -267,45 +127,3 @@ func TestAnUplinkDeclaringTheResolverFileAsAResourceIsRefusedBesideTheResolver(t t.Errorf("the uplink's holder renders %v beside the resolver", got.Facts) } } - -// A path is compared as the machine writes it: cleaned. Each spelling of /etc/hosts is /etc/hosts. -func TestAnUncleanSpellingOfASeatsPathIsTheSeatsPath(t *testing.T) { - for _, spelled := range []string{"/etc//hosts", "/etc/./hosts", "/etc/../etc/hosts", "/etc/hosts/"} { - stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ - {"id": "hosts", "type": "file", "path": spelled, "content": "10.0.0.1 x\n"}}} - if got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; "); !strings.Contains(got, - `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { - t.Errorf("%s from a module not claiming node-hostname was not refused: %q", spelled, got) - } - holder := hostnameHolder(map[string]any{"id": "hosts", "type": "file", "path": spelled, "content": "x"}, - map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"}) - if err := CanHold(holder, hostnameSeat(t)); err != nil { - t.Errorf("a holder declaring %s was refused: %v", spelled, err) - } - } -} - -// A seat names a role's own path, never the root or a top-level directory. -func TestASeatNamesNoRootOrTopLevelDirectory(t *testing.T) { - for _, p := range []string{"/", "/etc"} { - m := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ - {Name: "a-seat", Scope: ScopeNode, Paths: []string{p}}}} - if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "top-level directory") { - t.Errorf("a module's seat naming %s was accepted: %q", p, got) - } - } -} - -// The catalogue as it is passes: no holder of a seat naming a path is missing one, and nobody else -// declares one. -func TestTheCataloguePassesTheSeatPathRules(t *testing.T) { - shelf := Shelf{} - for _, m := range theCatalogue(t) { - shelf[m.Module] = m - } - for _, p := range CatalogueProblems(shelf) { - if strings.Contains(p, "ADR 0287") { - t.Errorf("the catalogue breaks ADR 0287: %s", p) - } - } -} diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index e8e68b0..bef2fc7 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -2,7 +2,6 @@ package catalogue import ( "fmt" - "sort" "strings" ) @@ -493,16 +492,6 @@ var defaultSeats = append([]Seat{ // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. graphicalSessionSeats()...) -// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for -// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused; -// any other name is a module's own to define and claim. Some of the mesh's own seats predate this -// convention and are not yet renamed (git, npm-package-registry, the-private-network) — those are -// in the set, so they resolve by name, not by prefix. the-artifact-store was renamed on 2026-09-30 -// (novox/hq ADR 0156) and resolves through the alias table on a mesh that knew it. -func isSystemSeatName(name string) bool { - return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-") -} - // seats is the working set the lookups read. It starts as the compiled defaults and is replaced by // what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a // change to data, not to this code. @@ -652,79 +641,6 @@ func SeatDelivering(provision string) (Seat, bool) { return Seat{}, false } -// claimProblems is what is wrong with a manifest's claims and the seats it defines. -// -// A claim is one of three things (novox/hq ADR 0121): a **system seat** the control plane defines — -// checked for scope and, if it delivers a provision, that the claimant provides it; a **system name -// the mesh does not define** (`mesh-*`/`node-*`) — refused, because that namespace is the control -// plane's; or a **module-defined seat** — valid only when this manifest also declares it, since a -// module may coordinate its own instances through a seat of its own but may not invent one by -// claiming it. A module's own seat declaration may not sit in the system namespace or shadow a -// system seat. -func claimProblems(m Manifest) []string { - var problems []string - - defined := map[string]SeatDeclaration{} - for _, d := range m.DefinesSeats { - if _, isSystem := SeatNamed(d.Name); isSystem || isSystemSeatName(d.Name) { - problems = append(problems, fmt.Sprintf( - "%s defines a seat %q in the mesh's own namespace; a module's seat is named outside "+ - "mesh-*/node-* (novox/hq ADR 0121)", m.Module, d.Name)) - continue - } - defined[d.Name] = d - } - - for _, c := range m.Claims { - if s, known := SeatNamed(c.Name); known && s.HeldByTheEngine { - // The node's engine holds it on every node, and no module may (novox/hq ADR 0563 §1): a module answering - // root-free or a hand-over would be an agent answering it. - problems = append(problems, fmt.Sprintf("%s claims %s, which every node's engine holds itself and no "+ - "module may (novox/hq ADR 0563 §1)", m.Module, c.Name)) - continue - } - if _, known := SeatNamed(c.Name); known { - // **A seat's scope and what it delivers are not judged here** (novox/hq ADR 0122). - // This function runs wherever a manifest is parsed, and one of those places is the - // build machine, which has no store: there, `SeatNamed` answers from the set the - // binary shipped with, so a build would be refused for disagreeing with a compiled - // copy of data the control plane owns. Exactly that happened — a holder of the bus - // seat was refused for not providing what a stale compiled row said the seat - // delivered, while the store's own row said otherwise. - // - // Both checks moved to CatalogueProblems, which only ever runs in the control plane, - // after UseSeats has replaced the set with the store's. - continue - } - if replaced, retired := RetiredSeats[c.Name]; retired { - problems = append(problems, fmt.Sprintf("%s claims the seat %q, which is retired: its work is %s", m.Module, c.Name, replaced)) - continue - } - if isSystemSeatName(c.Name) { - problems = append(problems, fmt.Sprintf( - "%s claims %q, which is a seat in the mesh's own namespace (mesh-*/node-*) that it "+ - "does not define (novox/hq ADR 0121) — the seats are: %s", m.Module, c.Name, seatNames())) - continue - } - d, ours := defined[c.Name] - if !ours { - // **A claim on a seat this manifest does not declare is not the parser's to judge.** - // A module may hold a seat another module declared — that is why ADR 0126 has callers - // name the seat and not its provider, so an implementation can be replaced without - // touching a caller. Whether the seat exists is a fact about the whole catalogue, so - // the refusal is at registration, where every declaration is in view - // (`CatalogueProblems`: "which no module declares and the mesh does not define"). - continue - } - if c.At() != d.At() { - problems = append(problems, fmt.Sprintf( - "%s claims its own seat %s at scope %q, having declared it at %q", - m.Module, c.Name, c.At(), d.At())) - } - } - return problems -} - // CanHold is why a module could not hold a seat, or nothing: its definition must claim the seat at // the seat's scope, and provide what the seat delivers, if it delivers anything. The seat is the // store's row, so this is judged only where the store's set is loaded — at registration and in the @@ -783,15 +699,6 @@ func providesAt(m Manifest, provision, scope string) bool { return false } -func seatNames() string { - names := make([]string, 0, len(seats)) - for _, s := range seats { - names = append(names, s.Name) - } - sort.Strings(names) - return strings.Join(names, ", ") -} - // HolderAmong is which of several providers of a provision holds the seat that delivers it. // // Found by the (node, module) pair, because a provider is identified by both (novox/hq to-be 23): @@ -984,13 +891,6 @@ func loginShellVerbs() []Verb { // delivery.AskKinds). func buildAskKinds() []string { return []string{"delivery", "check", "recheck"} } -// RetiredSeats are seats the mesh no longer defines, each with what replaced it: a claim or a use of one is refused -// naming its replacement, not as a seat nobody declares. -var RetiredSeats = map[string]string{ - "node-build-agent": "node-build-go, node-build-node and node-build-image, held by the build modules (novox/hq ADR 0525 §10)", - "mesh-build-machine": "node-build-go, node-build-node and node-build-image, held by the build modules (novox/hq ADR 0190, ADR 0525 §10)", -} - // buildSeatVerbs are what a build module answers on its own node for its seat (ADR 0525 §10). func buildSeatVerbs(language string) []Verb { return []Verb{ diff --git a/go/catalogue/seats_declared.go b/go/catalogue/seats_declared.go index 94f0ffa..11278aa 100644 --- a/go/catalogue/seats_declared.go +++ b/go/catalogue/seats_declared.go @@ -2,8 +2,6 @@ package catalogue import ( "fmt" - "regexp" - "sort" "strings" ) @@ -21,15 +19,6 @@ import ( // satisfies the protocol are facts about the *catalogue* — so they are checked at registration, // by CatalogueProblems, which is the last moment the mesh can still say no. -// meshSeatPrefix is reserved to the mesh. The prefix *is* the reservation rule: no list of -// reserved names to maintain, no way for the mesh's own namespace to be colonised by a manifest, -// and nothing to keep in step when a mesh seat is added. -const meshSeatPrefix = "mesh-" - -// retiredLoginShell is the one name outside the prefix a module may not declare: the login shell's, -// from when a module declared it (novox/hq ADR 0176), before it became the mesh's (ADR 0204). -const retiredLoginShell = "login-shell" - // A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says, // and what it answers. A caller declares that it uses the *seat*, never the module, so the // implementation can be replaced under it. @@ -103,162 +92,6 @@ func (s SeatDeclaration) At() string { return s.Scope } -// verbs is everything the protocol names, for the checks that do not care which half. -func (s SeatDeclaration) verbs() []string { - out := append([]string{}, s.Accepts...) - out = append(out, s.Emits...) - return append(out, VerbNames(s.Serves)...) -} - -// declaredSeatProblems is what one manifest can be judged on alone. -func declaredSeatProblems(m Manifest) []string { - var problems []string - seen := map[string]bool{} - - for _, s := range m.DefinesSeats { - switch { - case s.Name == "": - problems = append(problems, fmt.Sprintf("%s declares a seat with no name", m.Module)) - continue - case !name.MatchString(s.Name): - problems = append(problems, fmt.Sprintf( - "%s declares a seat named %q, which is not a usable name", m.Module, s.Name)) - continue - case strings.HasPrefix(s.Name, meshSeatPrefix): - // The mesh's own code dereferences its seats by name — the resolver *is* the thing - // that finds the store — so the prefix is not a convention, it is a namespace. - problems = append(problems, fmt.Sprintf( - "%s declares a seat named %q; %q is reserved to the mesh, which defines its own "+ - "seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*")) - continue - } - if s.Name == retiredLoginShell { - // The name ADR 0176 gave the login shell when the zsh module declared it. The seat is - // the mesh's now, so a module declaring the old name would be a second login shell - // beside it, with a protocol of its own (novox/hq ADR 0204). - problems = append(problems, fmt.Sprintf( - "%s declares a seat named %q; the login shell is the mesh's own seat %s, which a shell "+ - "module claims and none declares (novox/hq ADR 0204)", m.Module, s.Name, LoginShellSeat)) - continue - } - if seen[s.Name] { - problems = append(problems, fmt.Sprintf( - "%s declares the seat %q twice", m.Module, s.Name)) - continue - } - seen[s.Name] = true - - if _, isMesh := SeatNamed(s.Name); isMesh { - problems = append(problems, fmt.Sprintf( - "%s declares %q, which is a seat the mesh already defines", m.Module, s.Name)) - } - switch s.At() { - case ScopeNode, ScopeSite, ScopeMesh: - default: - problems = append(problems, fmt.Sprintf( - "%s declares seat %s at scope %q; a seat is held per node, per site or per mesh", - m.Module, s.Name, s.Scope)) - } - // A seat with an empty protocol is allowed, and is the mesh saying what a machine is: - // which module is this node's packet filter, or its showcase. Design 26 calls it a seat - // that delivers nothing, and that is most of the node-scoped ones. ADR 0126's "a declared - // seat carries a protocol" governs what a holder must satisfy, not that every seat offers - // something — a marker seat's protocol is satisfied by holding it. Nothing can reach this - // state by accident: a mistyped field name is refused by the parser above, so an empty - // protocol was written as one. - for _, v := range s.verbs() { - if !name.MatchString(v) { - problems = append(problems, fmt.Sprintf( - "%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v)) - } - } - problems = append(problems, trafficProblems(m, s)...) - problems = append(problems, declaredSeatPathProblems(m, s)...) - problems = append(problems, declaredSeatNeedsProblems(m, s)...) - } - - for _, u := range m.Uses { - if !name.MatchString(u) { - problems = append(problems, fmt.Sprintf("%s uses %q, which is not a usable seat name", m.Module, u)) - } - } - return problems -} - -// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone. -func trafficProblems(m Manifest, s SeatDeclaration) []string { - var problems []string - if s.Kinded && !KindedBenches[s.Name] { - problems = append(problems, fmt.Sprintf( - "%s declares %s as a kinded bench; only channel and intake are kinded, and another is a "+ - "decision, recorded (novox/hq ADR 0234)", m.Module, s.Name)) - } - if s.Kinded && len(s.ByCaller) > 0 { - problems = append(problems, fmt.Sprintf( - "%s declares %s kinded and names verbs by their caller; a kinded bench's subjects carry the kind", - m.Module, s.Name)) - } - for _, v := range s.ByCaller { - inAccepts, inEmits := false, false - for _, a := range s.Accepts { - inAccepts = inAccepts || a == v - } - for _, e := range s.Emits { - inEmits = inEmits || e == v - } - if !inAccepts && !inEmits { - problems = append(problems, fmt.Sprintf( - "%s names %s.%s by its caller, which the seat neither accepts nor emits", m.Module, s.Name, v)) - } - } - for _, v := range s.Proofs { - if !name.MatchString(v) || strings.Contains(v, ".") { - problems = append(problems, fmt.Sprintf("%s declares the proof %s.%s, which is not a usable verb", - m.Module, s.Name, v)) - } - } - if len(s.Proofs) > 0 && !s.Kinded { - problems = append(problems, fmt.Sprintf( - "%s declares proofs on %s, which is not kinded; a proof is asked by a holder of a kind", - m.Module, s.Name)) - } - for _, r := range s.Records { - kept := false - for _, st := range m.State { - kept = kept || st.Name == r - } - if !kept { - problems = append(problems, fmt.Sprintf( - "%s says %s's users read its records %q, which it keeps no state of", m.Module, s.Name, r)) - } - } - return problems -} - -// declaredSeatNeedsProblems is what one declaration's needs can be judged on alone (novox/hq ADR -// 0378 §8): only a node seat is ever held beside another on the same node, so only a node seat may -// carry needs; and a seat needing itself names nothing that was not already true of holding it. -// Whether each named need itself exists as a node seat somewhere in the catalogue is CatalogueProblems' -// to say, once every manifest is known. -func declaredSeatNeedsProblems(m Manifest, s SeatDeclaration) []string { - var problems []string - if len(s.Needs) == 0 { - return problems - } - if s.At() != ScopeNode { - problems = append(problems, fmt.Sprintf( - "%s declares %s needing %s, and only a node seat needs another held beside it "+ - "(novox/hq ADR 0378 §8)", m.Module, s.Name, strings.Join(s.Needs, ", "))) - } - for _, need := range s.Needs { - if need == s.Name { - problems = append(problems, fmt.Sprintf( - "%s declares %s needing itself (novox/hq ADR 0378 §8)", m.Module, s.Name)) - } - } - return problems -} - // A Shelf is every manifest the mesh has registered, by module name. type Shelf map[string]Manifest @@ -276,176 +109,6 @@ func IsUndeclaredSeat(problem string) bool { return strings.HasSuffix(problem, UndeclaredSeat) || strings.HasSuffix(problem, UnheldSeat) } -func CatalogueProblems(shelf Shelf) []string { - var problems []string - // A stream name has one owner (novox/hq ADR 0363). - problems = append(problems, StreamNameProblems(shelf)...) - // A module that hears a restricted stream runs as an account of its own (novox/hq ADR 0376 §2). - problems = append(problems, RestrictedHearingProblems(shelf)...) - // A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6). - problems = append(problems, WorkQueueConsumedElsewhereProblems(shelf)...) - // Every offer says its credential's form, never left to a default (the lead, 2026-10-29, for novox/hq ADR 0570). - for _, module := range shelfOrder(shelf) { - problems = append(problems, CredentialFormProblems(shelf[module])...) - // And its data in the bus's predefined forms, a stream of its own refused (the lead, 2026-10-29). - problems = append(problems, PredefinedFormProblems(shelf[module])...) - } - - // Who declares what, and who declared it first. - declaredBy := map[string]string{} - declared := map[string]SeatDeclaration{} - for _, module := range shelfOrder(shelf) { - for _, s := range shelf[module].DefinesSeats { - if s.Name == "" { - continue - } - if first, taken := declaredBy[s.Name]; taken { - // The second loses. A seat name meaning two different protocols is the failure - // nobody could diagnose afterwards — a caller would bind to whichever happened - // to register first, and the symptom would appear in the other module. - problems = append(problems, fmt.Sprintf( - "%s declares the seat %q, which %s already declares; a seat name means one "+ - "protocol", module, s.Name, first)) - continue - } - declaredBy[s.Name] = module - declared[s.Name] = s - } - } - - exists := func(seat string) bool { - if _, isMesh := SeatNamed(seat); isMesh { - return true - } - _, ok := declaredBy[seat] - return ok - } - - // atNode says whether a seat, compiled or declared, is a node seat — judged here because only - // the whole catalogue knows a declared one's scope (novox/hq ADR 0378 §8). - atNode := func(seat string) bool { - if s, isMesh := SeatNamed(seat); isMesh { - return s.Scope == ScopeNode - } - if s, ok := declared[seat]; ok { - return s.At() == ScopeNode - } - return false - } - - // A module-declared seat's needs name node seats that hold beside it (novox/hq ADR 0378 §8): - // whether one exists at all, compiled or declared by any module, only the whole catalogue can - // say — a single manifest's own problems (declaredSeatProblems) judge its needs no further. - for _, module := range shelfOrder(shelf) { - for _, s := range shelf[module].DefinesSeats { - for _, need := range s.Needs { - if !exists(need) { - problems = append(problems, fmt.Sprintf( - "%s declares %s needing %s, "+UndeclaredSeat, module, s.Name, need)) - } else if !atNode(need) { - problems = append(problems, fmt.Sprintf( - "%s declares %s needing %s, which is not a node seat (novox/hq ADR 0378 §8)", - module, s.Name, need)) - } - } - } - } - - // A seat verb a module invokes is one some holder serves (issue 774). - problems = append(problems, seatInvokeProblems(shelf, declared)...) - - // Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses. - named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy) - problems = append(problems, pathProblems...) - - // Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2). - kindsTaken := map[string]string{} - - for _, module := range shelfOrder(shelf) { - m := shelf[module] - for _, c := range m.Claims { - if c.Kind != "" { - if _, isModuleSeat := declared[c.Name]; !isModuleSeat { - problems = append(problems, fmt.Sprintf( - "%s claims %s of kind %q, and only a kinded bench takes a kind", module, c.Name, c.Kind)) - } - } - } - - // A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the - // same refusal, at the same moment, from a set nobody maintains by hand. - for _, u := range m.Uses { - if replaced, retired := RetiredSeats[u]; retired && !exists(u) { - problems = append(problems, fmt.Sprintf("%s uses the seat %q, which is retired: its work is %s", module, u, replaced)) - continue - } - if !exists(u) { - problems = append(problems, fmt.Sprintf( - "%s uses the seat %q, "+UndeclaredSeat, - module, u)) - } - } - - for _, c := range m.Claims { - if replaced, retired := RetiredSeats[c.Name]; retired && !exists(c.Name) { - problems = append(problems, fmt.Sprintf("%s claims the seat %q, which is retired: its work is %s", module, c.Name, replaced)) - continue - } - if !exists(c.Name) { - problems = append(problems, fmt.Sprintf( - "%s claims the seat %q, "+UndeclaredSeat, - module, c.Name)) - continue - } - s, isModuleSeat := declared[c.Name] - if !isModuleSeat { - // **A mesh seat is judged here and nowhere else** (novox/hq ADR 0122): the set is - // the store's, and this is the only place that runs with the store's set loaded. - // The parser cannot do it — it also runs on the build machine, against whatever - // set that binary was compiled with. - seat, _ := SeatNamed(c.Name) - if err := CanHold(m, seat); err != nil { - problems = append(problems, err.Error()) - } - continue - } - problems = append(problems, kindProblems(module, c, s, kindsTaken)...) - if c.At() != s.At() { - problems = append(problems, fmt.Sprintf( - "%s claims %s at scope %q, and %s declares it at %s", - module, c.Name, c.At(), declaredBy[c.Name], s.At())) - } - // A holder that does not answer what the seat promises is a caller's timeout, found - // at assignment instead. - if missing := unserved(m, c, s); len(missing) > 0 { - problems = append(problems, fmt.Sprintf( - "%s claims %s but does not serve %s, which that seat's protocol promises", - module, c.Name, strings.Join(missing, ", "))) - } - // And owns what the seat's holder owns, as a holder of the mesh's own seats does (ADR 0287). - if missing := missingPaths(m, asSeatPaths(s.Paths)); len(missing) > 0 { - problems = append(problems, fmt.Sprintf( - "%s claims %s but does not declare %s, which that seat's holder owns (novox/hq ADR 0287)", - module, c.Name, strings.Join(missing, ", "))) - } - } - // Only a claimant declares a seat's path (novox/hq ADR 0287). - problems = append(problems, unclaimedSeatPaths(m, named)...) - } - // **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or - // speaking for a kind that proves its sender, is never carried by a machine's runtime. - for _, module := range shelfOrder(shelf) { - m := shelf[module] - if why := TrustedHolding(m, declared); why != "" && m.RunsAs == "" { - problems = append(problems, fmt.Sprintf( - "%s must run as an account of its own (runs-as): %s, and the machine's runtime runs as the "+ - "operator's account, which every agent runs as (novox/hq ADR 0259)", module, why)) - } - } - sort.Strings(problems) - return problems -} - // ChannelCapabilities is the fixed vocabulary `channel-capabilities/1` (novox/hq ADR 0234 §2): a word // outside it is refused. `max-length:` takes a number. var ChannelCapabilities = map[string]bool{ @@ -455,119 +118,6 @@ var ChannelCapabilities = map[string]bool{ "verified-sender": true, "exact-render": true, "code-factor": true, "key-factor": true, } -var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`) - -// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a -// seat that is not kinded. -func capabilityProblems(module string, c Claim, kinded bool) []string { - if len(c.Capabilities) == 0 { - return nil - } - if !kinded { - return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them", - module, c.Name)} - } - var problems []string - for _, w := range c.Capabilities { - if !ChannelCapabilities[w] && !maxLength.MatchString(w) { - problems = append(problems, fmt.Sprintf( - "%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w)) - } - } - return problems -} - -// kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind, -// a usable name; any other seat takes none. -func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string { - if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 { - return problems - } - switch { - case !s.Kinded && c.Kind != "": - return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind", - module, c.Name, c.Kind)} - case !s.Kinded: - return nil - case c.Kind == "": - return []string{fmt.Sprintf("%s claims the kinded bench %s and names no kind", module, c.Name)} - case !name.MatchString(c.Kind) || strings.Contains(c.Kind, "."): - return []string{fmt.Sprintf("%s claims %s of kind %q, which is not a usable name", module, c.Name, c.Kind)} - } - key := c.Name + "/" + c.Kind - if first, ok := taken[key]; ok && first != module { - return []string{fmt.Sprintf("%s claims %s of kind %q, which %s already claims; a kind has one holder", - module, c.Name, c.Kind, first)} - } - taken[key] = module - return nil -} - -// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools -// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool -// is code the module either has or has not written — under the claim's serves, or among its own. -func unserved(m Manifest, c Claim, s SeatDeclaration) []string { - has := map[string]bool{} - for _, t := range c.ServesFor(m) { - has[t] = true - } - var missing []string - for _, t := range s.Serves { - if !has[t.Name] { - missing = append(missing, t.Name) - } - } - return missing -} - -// shelfOrder is the catalogue in a stable order, so two runs report the same problems in the same -// sequence — a refusal that reorders itself is a refusal nobody can diff. -func shelfOrder(shelf Shelf) []string { - out := make([]string, 0, len(shelf)) - for k := range shelf { - out = append(out, k) - } - sort.Strings(out) - return out -} - -var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`) - -// RunsAsProblems is what one manifest's `runs-as` is held to (novox/hq ADR 0259 §8): an account of the -// module's own making — a `user` resource of that name — that owns its secrets, with a bus account of its own, -// and that is neither root nor the operator's. -func RunsAsProblems(m Manifest) []string { - if m.RunsAs == "" { - return nil - } - var problems []string - say := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) } - switch { - case !accountName.MatchString(m.RunsAs): - say("%s runs as %q, which is not an account name of the module's own", m.Module, m.RunsAs) - return problems - case m.RunsAs == "root": - say("%s runs as root; a module of its own account runs as an account it makes, never root", m.Module) - } - made := false - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == m.RunsAs { - made = true - } - } - if !made { - say("%s runs as %s, which it does not make: a user resource named %s", m.Module, m.RunsAs, m.RunsAs) - } - if _, has := m.OwnSecrets["broker"]; !has { - say("%s runs as its own account and declares no own secret broker: its runtime reaches the bus on an "+ - "account of its own", m.Module) - } - if m.SecretsOwner != m.RunsAs { - say("%s runs as %s, and its secrets belong to %q: they are the account's own", m.Module, m.RunsAs, m.SecretsOwner) - } - return problems -} - // TrustedHolding is why a module must run as its own account (novox/hq ADR 0259 §8), or "": it holds a seat // whose events it says to one caller each (a warrant), or speaks for a kind of a kinded bench that proves // its sender, or is private (a link's code is shown there). None may be carried by the machine's runtime, which diff --git a/go/catalogue/seats_declared_test.go b/go/catalogue/seats_declared_test.go index 41dfabc..33766d7 100644 --- a/go/catalogue/seats_declared_test.go +++ b/go/catalogue/seats_declared_test.go @@ -1,240 +1,8 @@ package catalogue -import ( - "strings" - "testing" -) - -func problemsFor(t *testing.T, shelf Shelf) string { - t.Helper() - return strings.Join(CatalogueProblems(shelf), "; ") -} - func telegram() Manifest { return Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []SeatDeclaration{{ Name: "telegram-sender", Scope: ScopeMesh, Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []Verb{{Name: "status"}}, }}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}} } - -// The whole point: a module contributes a capability without the mesh being changed. -func TestAModuleDeclaresItsOwnSeatAndHoldsIt(t *testing.T) { - shop := Manifest{Module: "shop", Uses: []string{"telegram-sender"}} - if got := problemsFor(t, Shelf{"telegram": telegram(), "shop": shop}); got != "" { - t.Fatalf("a declared seat and its caller were refused: %s", got) - } -} - -// The prefix is the reservation rule, so there is no list to maintain and none to drift. -func TestAModuleCannotDeclareAMeshSeat(t *testing.T) { - for _, n := range []string{"mesh-broker", "mesh-anything", "mesh-store"} { - m := Manifest{Module: "impostor", DefinesSeats: []SeatDeclaration{{Name: n, Accepts: []string{"x"}}}} - got := strings.Join(declaredSeatProblems(m), "; ") - if !strings.Contains(got, "reserved to the mesh") { - t.Fatalf("%q was accepted as a module's seat: %q", n, got) - } - } -} - -// A seat name meaning two protocols is the failure nobody could diagnose afterwards. -func TestTwoModulesCannotDeclareTheSameSeat(t *testing.T) { - other := Manifest{Module: "aardvark", DefinesSeats: []SeatDeclaration{{ - Name: "telegram-sender", Scope: ScopeMesh, Accepts: []string{"something-else"}}}} - got := problemsFor(t, Shelf{"telegram": telegram(), "aardvark": other}) - if !strings.Contains(got, "already declares") { - t.Fatalf("both declarations stood: %s", got) - } - // The first declarer keeps it; only the second is refused. - if strings.Count(got, "already declares") != 1 { - t.Fatalf("expected exactly one refusal: %s", got) - } -} - -// Where ADR 0110's guarantee lands under a derived set: a typo is refused, not resolved to -// nothing at runtime. And a seat verb a module invokes is one a holder serves (issue 774, sweep item 1: messenger -// invoked a verb nothing answered, and Telegram held every ask silently). -func TestUsingASeatNobodyDeclaresIsRefused(t *testing.T) { - unclaimed := telegram() - unclaimed.Claims = nil - for _, row := range []struct { - name, want string - shop Manifest - seat Manifest - note bool // over some manifests alone it is a note (IsUndeclaredSeat) - }{ - {"a misspelled seat used", `uses the seat "telegram-sendr", ` + UndeclaredSeat, - Manifest{Module: "shop", Uses: []string{"telegram-sendr"}}, telegram(), true}, - {"a declared verb invoked of a held seat", "", Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, telegram(), false}, - {"a verb the seat does not promise", `the seat telegram-sender serves no verb "resend"`, - Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.resend"}}, telegram(), false}, - {"a verb of a misspelled seat", `a verb of the seat "telegram-sendr", ` + UndeclaredSeat, - Manifest{Module: "shop", Invokes: []string{"seat:telegram-sendr.status"}}, telegram(), true}, - {"a verb of a declared seat nobody claims", `a verb of the seat "telegram-sender", ` + UnheldSeat, - Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, unclaimed, true}, - {"a verb of a mesh seat whose holder lives in another repository", "", - Manifest{Module: "shop", Invokes: []string{"seat:mesh-watchdogs.signals"}}, telegram(), false}, - {"a verb a mesh seat does not promise", `the seat mesh-controller serves no verb "root-freed"`, - Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-freed"}}, telegram(), false}, - {"the controller's verb: the controller answers its seat", "", - Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-free"}}, telegram(), false}, - {"the engine's verb: every node's engine holds its seat", "", - Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-free"}}, telegram(), false}, - {"a verb the engine's seat does not promise", `the seat node-engine serves no verb "root-freed"`, - Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-freed"}}, telegram(), false}, - } { - got := CatalogueProblems(Shelf{"telegram": row.seat, "shop": row.shop}) - joined := strings.Join(got, "; ") - if row.want == "" && joined != "" || row.want != "" && (len(got) != 1 || !strings.Contains(joined, row.want) || - IsUndeclaredSeat(got[0]) != row.note) { - t.Errorf("%s: %s", row.name, joined) - } - } -} - -// A holder that does not answer what the seat promises is a caller's timeout, found here instead. -func TestAHolderMustServeWhatItsSeatPromises(t *testing.T) { - m := telegram() - m.Tools = nil // declares the seat, serves none of it - got := problemsFor(t, Shelf{"telegram": m}) - if !strings.Contains(got, "does not serve status") { - t.Fatalf("a holder was accepted that answers nothing its seat promises: %s", got) - } -} - -// The claim may say what it serves for the role instead, when the module's own tools are not the -// seat's verbs (ADR 0160). -func TestAClaimMayNameWhatItServesForTheSeat(t *testing.T) { - m := telegram() - m.Tools = []string{"telegram_send"} - m.Claims = append([]Claim(nil), m.Claims...) - for i := range m.Claims { - if m.Claims[i].Name == "telegram-sender" { - m.Claims[i].Serves = []string{"status"} - } - } - if got := problemsFor(t, Shelf{"telegram": m}); strings.Contains(got, "does not serve") { - t.Fatalf("a claim naming the seat's verb was refused: %s", got) - } -} - -// A seat with no protocol is a marker: which module is this node's showcase, or its packet filter. -// Most node-scoped seats are markers, so refusing one would refuse the majority of the set. -func TestASeatWithoutAProtocolIsAMarkerNotAMistake(t *testing.T) { - m := Manifest{Module: "vague", DefinesSeats: []SeatDeclaration{{Name: "something", Scope: ScopeNode}}} - if got := strings.Join(declaredSeatProblems(m), "; "); got != "" { - t.Fatalf("a marker seat was refused: %s", got) - } -} - -// A claim at the wrong scope is a different seat than the one declared. -func TestAClaimMustMatchTheDeclaredScope(t *testing.T) { - m := telegram() - m.Claims = []Claim{{Name: "telegram-sender", Scope: ScopeNode}} - got := problemsFor(t, Shelf{"telegram": m}) - if !strings.Contains(got, "scope") { - t.Fatalf("a claim at the wrong scope was accepted: %s", got) - } -} - -// The mesh's own seats still work, and are not shadowed by the derived half. -func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) { - // It delivers the bus, so its holder provides the bus — the rule this check now enforces. - m := Manifest{Module: "nats", - Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormNone}}}, - Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} - if got := problemsFor(t, Shelf{"nats": m}); got != "" { - t.Fatalf("a mesh seat was refused by the derived check: %s", got) - } -} - -// Only a node seat may carry needs (novox/hq ADR 0378 §8): a per-mesh seat's holder is not on any -// one node to depend on anything beside it there. -func TestOnlyANodeSeatMayCarryNeeds(t *testing.T) { - m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{ - Name: "mail-notifier", Scope: ScopeMesh, Needs: []string{"node-notifier"}}}} - got := strings.Join(declaredSeatProblems(m), "; ") - if !strings.Contains(got, "needing") || !strings.Contains(got, "node seat") { - t.Fatalf("a mesh-scope seat's needs were accepted: %q", got) - } -} - -// A seat needing itself names nothing (novox/hq ADR 0378 §8). -func TestASeatCannotNeedItself(t *testing.T) { - m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{ - Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"mail-notifier"}}}} - got := strings.Join(declaredSeatProblems(m), "; ") - if !strings.Contains(got, "needing itself") { - t.Fatalf("a seat needing itself was accepted: %q", got) - } -} - -// A need naming nothing node-scope — undeclared, or declared at another scope — is the whole -// catalogue's to say (novox/hq ADR 0378 §8). -func TestANeedMustNameANodeSeatSomewhereInTheCatalogue(t *testing.T) { - mail := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{ - Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"desk-beeper"}}}} - - // Nobody declares desk-beeper at all. - got := problemsFor(t, Shelf{"mail": mail}) - if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "no module declares") { - t.Fatalf("needing an undeclared seat was accepted: %q", got) - } - - // Declared, but at mesh scope: still not a node seat to hold beside it. - notNode := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeMesh}}} - got = problemsFor(t, Shelf{"mail": mail, "dunst": notNode}) - if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "not a node seat") { - t.Fatalf("needing a mesh-scope seat was accepted: %q", got) - } - - // Declared at node scope: accepted. - dunst := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeNode}}} - if got := problemsFor(t, Shelf{"mail": mail, "dunst": dunst}); got != "" { - t.Fatalf("needing a node seat another module declares was refused: %q", got) - } -} - -// A refusal that reorders itself between runs is a refusal nobody can diff. -func TestTheProblemsAreStable(t *testing.T) { - shelf := Shelf{"telegram": telegram(), "shop": {Module: "shop", Uses: []string{"nope"}}, - "other": {Module: "other", Uses: []string{"also-nope"}}} - first, second := problemsFor(t, shelf), problemsFor(t, shelf) - if first != second { - t.Fatalf("unstable:\n%s\n%s", first, second) - } -} - -// **A build machine has no store, so it may not judge a seat.** The set is data the control plane -// owns (novox/hq ADR 0122), and `ParseManifest` runs on the build machine too, against whatever set -// that binary was compiled with. When the two disagreed, a valid holder of the bus seat was refused -// mid-rollout — the compiled row said the seat delivered one provision, the store's row said -// another, and the build failed on the copy rather than the truth. The parser judges the manifest; -// the seat set judges the claim, where it is loaded. -func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) { - was := Seats() - t.Cleanup(func() { UseSeats(was) }) - - // A store whose bus seat delivers something this module does provide. - UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}}) - raw := []byte(`{"module":"a-bus","version":"1",` + - `"provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}],` + - `"claims":[{"name":"mesh-broker","scope":"mesh"}]}`) - - m, err := ParseManifest(raw) - if err != nil { - t.Fatalf("the parser refused a claim only the seat set can judge: %v", err) - } - if got := CatalogueProblems(Shelf{m.Module: m}); len(got) != 0 { - t.Fatalf("a holder that provides what the store says the seat delivers was refused: %v", got) - } - - // And with the store saying the seat delivers something else, registration is what refuses it. - UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "other-bus", Decision: "test"}}) - if _, err := ParseManifest(raw); err != nil { - t.Fatalf("the parser judged it the second time: %v", err) - } - got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ") - if !strings.Contains(got, `does not provide "other-bus"`) { - t.Fatalf("registration did not refuse a holder that cannot answer for the seat: %q", got) - } -} diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index 1a58ab1..653aaa3 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -1,274 +1,16 @@ package catalogue import ( - "reflect" - "regexp" "strings" "testing" ) // Defends novox/hq ADR 0110: a seat is a module assignment from a closed set. -// The set is closed, and changing it is a decision. -// -// **The count is asserted, and every entry names the record that made it a seat**, so the next -// person changing the set finds the argument rather than a number to edit — the pattern the host's -// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq -// and a row in to-be 26, not a new number here. -func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { - // A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined - // it and the one that seeded it. - record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`) - seen := map[string]bool{} - delivered := map[string]string{} - for _, s := range Seats() { - if seen[s.Name] { - t.Errorf("%s is in the set twice", s.Name) - } - seen[s.Name] = true - if !record.MatchString(s.Decision) { - t.Errorf("%s names %q as its decision; every seat names the record that made it one", - s.Name, s.Decision) - } - switch s.Scope { - case ScopeNode, ScopeSite, ScopeMesh: - default: - t.Errorf("%s is held per %q, which is not a scope", s.Name, s.Scope) - } - if s.Delivers != "" { - // Two seats answering for one provision would put the question "which one?" back, - // which is the question a seat exists to answer. - if other, twice := delivered[s.Delivers]; twice { - t.Errorf("%s and %s both deliver %q", other, s.Name, s.Delivers) - } - delivered[s.Delivers] = s.Name - } - } - // Forty-seven with mesh-placement (novox/hq ADR 0567), serving its seven verbs. - if s, ok := SeatNamed(PlacementSeat); !ok || s.Scope != ScopeMesh || !reflect.DeepEqual(VerbNames(s.Serves), - []string{"placement", "proposals", "propose", "withdraw", "again", "adopt", "hand-back"}) { - t.Errorf("the placement seat is %v, %v", ok, s) - } - // The checker's seat serves the try, which replaces the shell's merge check (ADR 0547 §10, ADR 0245). - var checkerVerbs []string - for _, s := range Seats() { - if s.Name == CheckerSeat { - for _, v := range s.Serves { - checkerVerbs = append(checkerVerbs, v.Name) - if v.Name == "try" && strings.Join(v.Replaces, "|") != "sh merge-check.sh|docker run postgres" { - t.Errorf("try replaces %v", v.Replaces) - } - } - } - } - if strings.Join(checkerVerbs, ",") != "try,try-read,try-end" { - t.Errorf("the checker seat serves %v", checkerVerbs) - } - // The four health seats (novox/hq ADR 0564 clause 1): at mesh scope, each held once, each naming ADR 0564, with the - // verbs and events the record names; the events keep the controller's names (clause 3). - for _, c := range []struct{ seat, verbs, emits string }{ - {ConditionsSeat, "conditions,silence,compare,take-over,hand-back,resync", "condition-raised,condition-changed,condition-cleared"}, - {WatchdogsSeat, "signals,stuck,presence,dead-letters,resync", ""}, - {SelfCheckSeat, "doctor,probes", "checked"}, - {HealersSeat, "healers,heal,drill", "healer-acted"}, - // And mesh-config's switch (ADR 0565 §4), beside its config. - {"mesh-config", "config,compare,take-over,hand-back", ""}, - // And ADR 0565 clause 1's two. - {WantsSeat, "upgrade,bus", "policy-changed"}, - {HandActsSeat, "hand-act,hand-acts,warranted", "act-recorded"}, - // ADR 0571 clause 2: the artifact store's sweep. - {ArtifactSweepSeat, "artifacts,held,collect,mirrors,images", "artifacts-gone"}, - } { - s, ok := SeatNamed(c.seat) - if !ok || s.Scope != ScopeMesh || s.Replicated || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 05") || - strings.Join(VerbNames(s.Serves), ",") != c.verbs || strings.Join(s.Emits, ",") != c.emits { - t.Errorf("the health seat %s is %v, %+v", c.seat, ok, s) - } - } - // The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its six - // verbs and three events, held by no assignment, and a module's claim of it refused. - if engine, ok := SeatNamed(EngineSeat); !ok || engine.Scope != ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine { - t.Errorf("node-engine is %+v, not a node seat delivering nothing that the engine holds", engine) - } else { - var verbs []string - for _, v := range engine.Serves { - verbs = append(verbs, v.Name) - } - // acts reads one act by its id too (ADR 0563 §9: the seat's tools are its command line). - if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil { - t.Errorf("acts takes no id: %v", engine.Serves[5].Input) - } - if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose" || - strings.Join(engine.Emits, ",") != "asked,done,refused" { - t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits) - } - } - if _, err := ParseManifest([]byte(`{"module":"thing","version":"1","claims":[{"name":"node-engine","scope":"node"}]}`)); err == nil || - !strings.Contains(err.Error(), "node-engine") { - t.Errorf("a module's claim of node-engine was not refused: %v", err) - } - // Forty-six with the three build seats replacing node-build-agent and the retired mesh-build-machine (novox/hq - // ADR 0525 §2, §10); forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes. - // Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired - // into node-uplink (novox/hq ADR 0223); thirty-seven - // since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with - // node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215); - // thirty-six with mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199, now - // node-hostname); thirty-four - // with node-hotkeys (ADR 0212); thirty-three with node-power (ADR 0211); thirty-two since the - // graphical session's eleven (ADR 0208); twenty-one with node-package-manager and - // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, - // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired - // mesh-build-machine row goes, when no registered manifest claims it. - // Forty-eight with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-nine with node-engine (novox/hq ADR 0563 §1); - // fifty-three with the four health seats (novox/hq ADR 0564 clause 1); fifty-five with mesh-wants and mesh-hand-acts - // (novox/hq ADR 0565 clause 1); fifty-six with mesh-artifact-sweep (novox/hq ADR 0571 clause 2). - if len(Seats()) != 56 { - t.Errorf("the mesh defines %d seats rather than 56; the set is closed, so a change here is "+ - "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) - } -} - -// novox/hq ADR 0117: a machine's uplink is a seat, held per machine, and delivers nothing. -// -// **Nothing, because nothing may be required of it.** A holder only keeps its network manager from -// contradicting the mesh; a requirement resolving to it would make the manager the mesh's answer -// for something, and the manager's link is the one thing the mesh must never be able to break. -func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) { - seat, known := SeatNamed("node-uplink") - if !known { - t.Fatalf("the uplink is not a seat; the seats are: %s", seatNames()) - } - if seat.Scope != ScopeNode || seat.Delivers != "" || seat.Decision != "novox/hq ADR 0117" { - t.Fatalf("the uplink is %+v, not a node seat delivering nothing by ADR 0117", seat) - } - // And a manager's module can hold it without providing anything. - raw := []byte(`{"module":"networkmanager","version":"1","claims":[{"name":"node-uplink","scope":"node"}]}`) - if _, err := ParseManifest(raw); err != nil { - t.Fatalf("a network manager's module could not hold the uplink: %v", err) - } -} - func claimed(claims string) []byte { return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh","credential":{"form":"none"}}],"claims":` + claims + `}`) } -// **The refusal moved, it did not go** (novox/hq ADR 0118, superseding 0110). A module may now -// declare its own seats, so whether a claimed seat exists is a fact about the *catalogue* and not -// about the manifest in front of the parser: a claim on a seat another registered module declares -// is perfectly good, and the parser cannot tell the two cases apart. So the parser accepts it and -// registration refuses it — the same guarantee, at the same moment work would otherwise start, -// from a set nobody maintains by hand. -func TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration(t *testing.T) { - m, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) - if err != nil { - t.Fatalf("the parser judged a claim it cannot judge alone: %v", err) - } - - problems := CatalogueProblems(Shelf{m.Module: m}) - if len(problems) == 0 { - t.Fatal("a module invented a seat by claiming it, and registration allowed it") - } - joined := strings.Join(problems, "; ") - if !strings.Contains(joined, "the-anything") || !strings.Contains(joined, "no module declares") { - t.Fatalf("the refusal does not say the seat is nobody's: %v", problems) - } -} - -// And the same claim is fine once something declares that seat, which is the case the parser -// could not have distinguished. -func TestAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) { - claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) - if err != nil { - t.Fatal(err) - } - declarer := Manifest{Module: "someone", DefinesSeats: []SeatDeclaration{ - {Name: "the-anything", Scope: ScopeNode, Accepts: []string{"work"}}, - }} - if problems := CatalogueProblems(Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 { - t.Fatalf("a claim on a declared seat was refused: %v", problems) - } - // The second of a kind (issue 1952; the lead, 2026-10-29): a holder that lists its seat's event among its own emits - // would say it where no consumer hears it, so the manifest is refused, naming the seat's subject; the seat's - // holder saying nothing of its own is accepted. - holder := func(emits string) []byte { - return []byte(`{"module":"mesh-self-check","version":"1","emits":` + emits + `,"claims":[{"name":"mesh-self-check","scope":"mesh","serves":["doctor","probes"]}]}`) - } - if _, err := ParseManifest(holder(`["checked"]`)); err == nil || !strings.Contains(err.Error(), "mesh.seat.mesh-self-check.event.checked") { - t.Errorf("a seat's event listed as the module's own: %v", err) - } - if _, err := ParseManifest(holder(`[]`)); err != nil { - t.Errorf("a holder saying its seat's event on the seat was refused: %v", err) - } -} - -// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing -// what it means (novox/hq ADR 0121). But it may not define one in the mesh's own namespace. -func TestAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) { - ok := []byte(`{"module":"showcase","version":"1","seats":[{"name":"the-showcase","scope":"node"}],` + - `"claims":[{"name":"the-showcase","scope":"node"}]}`) - if _, err := ParseManifest(ok); err != nil { - t.Fatalf("a module could not define and claim its own seat: %v", err) - } - // Claiming a name nobody defines is still refused — but **at registration, not here**: with - // seats declared by modules, a claim on a seat *another* module declares is good, and the - // parser cannot tell that from an invented name. See - // TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration. - claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) - if err != nil { - t.Fatalf("the parser judged a claim it cannot judge alone: %v", err) - } - if len(CatalogueProblems(Shelf{claimant.Module: claimant})) == 0 { - t.Fatal("a module claimed a seat nobody defines") - } - // A module may not carve its seat out of the mesh's own namespace. - bad := []byte(`{"module":"x","version":"1","seats":[{"name":"node-mine","scope":"node"}],` + - `"claims":[{"name":"node-mine","scope":"node"}]}`) - if _, err := ParseManifest(bad); err == nil || !strings.Contains(err.Error(), "own namespace") { - t.Fatalf("a module defined a seat in the mesh's namespace and was not refused: %v", err) - } -} - -// **At registration, not in the parser** (novox/hq ADR 0122): a seat's scope is a property of the -// set, the set is the store's, and the parser also runs on a build machine that has no store. -func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) { - m, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`)) - if err != nil { - t.Fatalf("the parser judged a scope it reads from data it may not have: %v", err) - } - got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ") - if !strings.Contains(got, "mesh seat") { - t.Fatalf("the refusal does not say which scope the seat is: %q", got) - } -} - -func TestADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) { - // Holding it makes the module the mesh's answer for the provision. A module that cannot answer - // would be the answer anyway, and every consumer would be sent to it. - // And refused at registration, where the seat set is the store's: what a seat delivers is - // data, so a compiled copy of it may not be what refuses a build (novox/hq ADR 0122). - raw := []byte(`{"module":"thing","version":"1","claims":[{"name":"git","scope":"mesh"}]}`) - m, err := ParseManifest(raw) - if err != nil { - t.Fatalf("the parser judged what a seat delivers: %v", err) - } - got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ") - if !strings.Contains(got, `does not provide "git"`) { - t.Fatalf("the refusal does not say what is missing: %q", got) - } -} - -func TestAClaimThatIsMalformedIsRefusedOnceForThat(t *testing.T) { - // Not a second time for being unknown: one mistake, one line. - _, err := ParseManifest(claimed(`[{"name":"Not A Name","scope":"node"}]`)) - if err == nil { - t.Fatal("a malformed claim was accepted") - } - if strings.Contains(err.Error(), "not a seat") { - t.Fatalf("a malformed claim was also called unknown: %v", err) - } -} - // The holder of a seat answers among several providers. func registryShelf() map[string]Manifest { @@ -380,48 +122,3 @@ func TestAFormerNameResolvesAfterARename(t *testing.T) { t.Fatalf("the holder recorded under the former name was not matched: %+v found=%v", holder, found) } } - -// 1887/A9a: the catalogue defines no seat node-build-agent, and a manifest claiming it is refused naming its -// replacement; the build modules' seats accept the three kinds in their pull order and serve current, pause, resume. -func TestNodeBuildAgentIsRetiredForTheBuildSeats(t *testing.T) { - for _, gone := range []string{"node-build-agent", "mesh-build-machine"} { - if _, ok := SeatNamed(gone); ok { - t.Errorf("the mesh still defines %s", gone) - } - } - _, err := ParseManifest([]byte(`{"module":"build-agent","version":"1","claims":[{"name":"node-build-agent","scope":"node"}]}`)) - if err == nil || !strings.Contains(err.Error(), `"node-build-agent", which is retired`) || !strings.Contains(err.Error(), "node-build-go") { - t.Fatalf("the claim is not refused naming its replacement: %v", err) - } - // And over a catalogue whose store still holds no row for it, a use of it is refused the same way. - user := Manifest{Module: "watcher", Uses: []string{"node-build-agent"}} - if joined := strings.Join(CatalogueProblems(Shelf{user.Module: user}), "; "); !strings.Contains(joined, "which is retired: its work is node-build-go") { - t.Fatalf("a use is not refused naming the replacement: %s", joined) - } - for _, name := range []string{"node-build-go", "node-build-node", "node-build-image"} { - s, ok := SeatNamed(name) - if !ok || s.Scope != ScopeNode || !s.PulledByKind || strings.Join(s.Accepts, ",") != "delivery,check,recheck" { - t.Fatalf("%s = %+v", name, s) - } - if !s.Yields { - t.Errorf("%s does not yield (ADR 0553)", name) - } - var verbs []string - for _, v := range s.Serves { - verbs = append(verbs, v.Name) - } - if strings.Join(verbs, ",") != "current,pause,resume" { - t.Errorf("%s serves %v", name, verbs) - } - } - // The build seats are the seats that yield, and no other (ADR 0553 §1). - var yielding []string - for _, s := range Seats() { - if s.Yields { - yielding = append(yielding, s.Name) - } - } - if strings.Join(yielding, ",") != "node-build-go,node-build-node,node-build-image" { - t.Errorf("the seats that yield are %v", yielding) - } -} diff --git a/go/catalogue/secret_family_test.go b/go/catalogue/secret_family_test.go index 506456d..95531ca 100644 --- a/go/catalogue/secret_family_test.go +++ b/go/catalogue/secret_family_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -10,7 +9,7 @@ import ( func familyManifest(t *testing.T, ownSecrets string) (Manifest, error) { t.Helper() - return ParseManifest([]byte(`{"module":"mounts","version":"1","own-secrets":{` + ownSecrets + `}}`)) + return decodeForTest([]byte(`{"module":"mounts","version":"1","own-secrets":{` + ownSecrets + `}}`)) } func TestAFamilyIsDeclaredIssuedOutsideWithOneStar(t *testing.T) { @@ -26,23 +25,6 @@ func TestAFamilyIsDeclaredIssuedOutsideWithOneStar(t *testing.T) { } } -func TestAMalformedFamilyIsRefused(t *testing.T) { - for name, c := range map[string]struct{ own, says string }{ - "made by the mesh": {`"smb-password-*":{"path":"/s/smb-password-*.secret","taken":"at-start"}`, "issued-by"}, - "no star in its path": {`"smb-password-*":{"path":"/s/smb-password.secret","issued-by":"outside"}`, - "exactly one *"}, - "two stars in its path": {`"smb-password-*":{"path":"/s/*/smb-password-*.secret","issued-by":"outside"}`, - "exactly one *"}, - "a star inside a name": {`"smb*password":{"path":"/s/x","issued-by":"outside"}`, "only ends a family"}, - "a name that is also a member": {`"smb-password-*":{"path":"/s/p-*","issued-by":"outside"}, - "smb-password-games":{"path":"/s/games","issued-by":"outside"}`, "also a member"}, - } { - if _, err := familyManifest(t, c.own); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says) - } - } -} - func TestAMemberIsFoundByItsFullNameAndLandsWhereTheStarIs(t *testing.T) { o := OwnSecrets{ "smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: IssuedOutside}, diff --git a/go/catalogue/served_by_setting_test.go b/go/catalogue/served_by_setting_test.go index 681038d..91c16b7 100644 --- a/go/catalogue/served_by_setting_test.go +++ b/go/catalogue/served_by_setting_test.go @@ -59,7 +59,7 @@ func composedLikeAce(t *testing.T, influxdb string, layers []Layer) map[string]s t.Helper() var ms []Manifest for _, raw := range []string{influxdb, grafanaLikeTheCatalogue} { - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/served_own_names.go b/go/catalogue/served_own_names.go index ca231c8..9599542 100644 --- a/go/catalogue/served_own_names.go +++ b/go/catalogue/served_own_names.go @@ -3,7 +3,6 @@ package catalogue import ( "fmt" "sort" - "strings" ) // A provider may tell its consumers its own composed names (novox/hq ADR 0346, issue 624). @@ -22,10 +21,6 @@ import ( // consumer told nothing — a refused provider machine takes every provision it offers down with it. The // plan says which key was left out, and why, on the provider's machine and under each consumer's need. -// ownNameFacts are what a served value may name of the provider's own composition: the names, as its -// own files read them (withOwnNames). -var ownNameFacts = []string{"name", "internal-name"} - // requirementsContributedTo is every requirement m contributes to, sorted. func requirementsContributedTo(m Manifest) []string { seen := map[string]bool{} @@ -43,19 +38,6 @@ func requirementsContributedTo(m Manifest) []string { return out } -// ownNameKeys is every name a served value of m may say for one requirement it contributes to: -// `name` and `internal-name`, and `name-` and `internal-name-` for each local name it -// contributes under. -func ownNameKeys(m Manifest, to string) []string { - keys := append([]string{}, ownNameFacts...) - for _, local := range sortedKeys(m.ContributesMany[to]) { - for _, fact := range ownNameFacts { - keys = append(keys, fact+"-"+local) - } - } - return keys -} - // ServedOwnNames fills the provider's own composed names into what it serves one provision's // consumers, from this resolution's composition of the provider's machine (ADR 0346). // @@ -138,65 +120,3 @@ func (r Resolution) ServedLeftOut(settings SettingsBy) []string { } return said } - -// checkServedOwnNames refuses a served `${bound:…}` that names a requirement the module does not -// contribute to, a fact other than its own names, or a local name it does not contribute under; and -// one with a default, since a served value is left out rather than given one (ADR 0346 §3, §4). -func checkServedOwnNames(m Manifest, provision, key, text string) []string { - var problems []string - contributes := requirementsContributedTo(m) - for _, parts := range bound.FindAllStringSubmatch(text, -1) { - to, fact, fallback := parts[1], parts[2], parts[3] - says := fmt.Sprintf("%s serves %s, and the value it serves as %q says %s", m.Module, provision, key, parts[0]) - switch { - case fallback != "": - problems = append(problems, says+": a served name takes no default; one the machine does not "+ - "compose leaves the key out (novox/hq ADR 0346)") - case !oneOf(contributes, to): - problems = append(problems, fmt.Sprintf( - "%s, and %s contributes to %s: a served value names only the provider's own names "+ - "(novox/hq ADR 0346)", says, m.Module, orNothing(quoted(contributes)))) - case !oneOf(ownNameKeys(m, to), fact): - problems = append(problems, fmt.Sprintf("%s, and what %s is called through %s is %s (novox/hq ADR 0346)", - says, m.Module, to, strings.Join(ownNameKeys(m, to), ", "))) - } - } - return problems -} - -func quoted(values []string) []string { - out := make([]string, len(values)) - for i, v := range values { - out[i] = fmt.Sprintf("%q", v) - } - return out -} - -// nestedOwnNames refuses a `${bound:…}` inside a served list or object: only a string value of its own is -// filled (ServedOwnNames), so a nested one would reach the consumer as the placeholder (ADR 0346 §5). -func nestedOwnNames(m Manifest, provision, key string, value any) []string { - var found []string - var walk func(v any) - walk = func(v any) { - switch x := v.(type) { - case string: - found = append(found, bound.FindAllString(x, -1)...) - case []any: - for _, e := range x { - walk(e) - } - case map[string]any: - for _, k := range sortedAnyKeys(x) { - walk(x[k]) - } - } - } - walk(value) - if len(found) == 0 { - return nil - } - return []string{fmt.Sprintf( - "%s serves %s, and the value it serves as %q holds %s inside a list or an object: a provider's own "+ - "name is filled only in a string value of its own (novox/hq ADR 0346)", - m.Module, provision, key, strings.Join(found, ", "))} -} diff --git a/go/catalogue/served_own_names_test.go b/go/catalogue/served_own_names_test.go index db00dcc..292947c 100644 --- a/go/catalogue/served_own_names_test.go +++ b/go/catalogue/served_own_names_test.go @@ -18,7 +18,7 @@ const aRegistryForge = `{"module":"forge","version":"1","requires":["route"], func aForgeManifest(t *testing.T) Manifest { t.Helper() - m, err := ParseManifest([]byte(aRegistryForge)) + m, err := decodeForTest([]byte(aRegistryForge)) if err != nil { t.Fatal(err) } @@ -130,29 +130,6 @@ func TestAConsumerElsewhereIsToldWhatTheProviderLeftOut(t *testing.T) { t.Fatalf("no need: %+v", got.Needs) } -func TestAServedNameIsCheckedWhenTheDefinitionIsRead(t *testing.T) { - for bad, want := range map[string]string{ - `"public-root":"https://${bound:route:name-api}/x"`: "what forge is called through route is name, internal-name, name-web, internal-name-web", - `"public-root":"https://${bound:postgres-database:name}/x"`: `forge contributes to "route"`, - `"public-root":"https://${bound:route:name-web|default:x}/x"`: "takes no default", - `"public-root":"https://${bound:route:at}/x"`: "what forge is called through route", - } { - raw := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`, bad, 1) - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: want a refusal saying %q, got %v", bad, want, err) - } - } - nested := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`, - `"roots":["https://${bound:route:name-web}/x"]`, 1) - if _, err := ParseManifest([]byte(nested)); err == nil || !strings.Contains(err.Error(), "inside a list or an object") { - t.Errorf("a nested own name is refused, not served as the placeholder: %v", err) - } - if _, err := ParseManifest([]byte(aRegistryForge)); err != nil { - t.Fatalf("its own name is accepted: %v", err) - } -} - // The provider's contributions file carries no own name on a consumer's entry (ADR 0346 §4): only what // is derived for that consumer is there (ADR 0202 §5), and the provider reads its own names in its files. func TestAProvidersOwnNameIsNotOnItsConsumersEntry(t *testing.T) { diff --git a/go/catalogue/setting_defaults.go b/go/catalogue/setting_defaults.go index 353b406..8f8ad4e 100644 --- a/go/catalogue/setting_defaults.go +++ b/go/catalogue/setting_defaults.go @@ -1,9 +1,7 @@ package catalogue import ( - "fmt" "sort" - "strings" ) // A preference has a default; the operator's own value has none (novox/hq ADR 0262, extending ADR @@ -68,122 +66,6 @@ var meshWords = map[string]bool{ PlacesSetting: true, AccessesSetting: true, NetworksSetting: true, } -// operatorsOwn are the words that, as what a key's name is about, say its value is the operator's and -// never a preference: a default for one would be the literal ADR 0112 removed from definitions. -var operatorsOwn = map[string]bool{ - "domain": true, "host": true, "hostname": true, "servername": true, "fqdn": true, "zone": true, - "realm": true, "tenant": true, "site": true, "timezone": true, - "issuer": true, "url": true, "uri": true, "webhook": true, "origin": true, "dsn": true, - "ip": true, "ipv4": true, "ipv6": true, - "email": true, "mail": true, "phone": true, "address": true, - "identity": true, "login": true, "user": true, "username": true, "account": true, "owner": true, - "uid": true, "gid": true, "puid": true, "pgid": true, - "password": true, "pass": true, "passwd": true, "passphrase": true, "secret": true, "token": true, - "key": true, "apikey": true, "bearer": true, "cert": true, "certificate": true, "credential": true, - "nameserver": true, "gateway": true, "subnet": true, "sender": true, "recipient": true, "contact": true, - "trusted": true, "whitelist": true, "peer": true, "bind": true, "listen": true, "upstream": true, - "proxy": true, "admin": true, "mac": true, -} - -// operatorsCompounds are names of two words that are the operator's though neither word alone says so -// at the end of a key: a client's identifier, and a name the world knows a site or server by. -var operatorsCompounds = map[string]bool{ - "client-id": true, "site-name": true, "server-name": true, "public-name": true, "smtp-relay": true, - "host-name": true, "user-name": true, "domain-name": true, "dns-server": true, - // Whom a rule lets in or keeps out: a list of addresses or networks. - "allow-from": true, "deny-from": true, "allow-list": true, -} - -// aboutAnAmount are first words that make a key about how many or whether, never about whom: -// `max-tokens` is a number, `show-hostname` a switch. Not `allow` or `use`: `allow-from` and -// `use-host` name whom. -var aboutAnAmount = map[string]bool{ - "max": true, "min": true, "num": true, "count": true, "show": true, "hide": true, "enable": true, - "disable": true, -} - -// operatorsWord is what in a key's name says its value is the operator's, or "". A key is about its -// last word — `url-timeout` is a timeout, `user-agent` an agent, `mail-domain` a domain — or its last two -// as one of operatorsCompounds. A plural is read as its singular. -func operatorsWord(key string) string { - words := strings.FieldsFunc(key, func(r rune) bool { return r == '-' || r == '_' || r == '.' }) - if len(words) == 0 || (len(words) > 1 && aboutAnAmount[words[0]]) { - return "" - } - for i, w := range words { - switch { - case operatorsOwn[w]: - case strings.HasSuffix(w, "ies") && operatorsOwn[strings.TrimSuffix(w, "ies")+"y"]: - words[i] = strings.TrimSuffix(w, "ies") + "y" - case strings.HasSuffix(w, "s") && operatorsOwn[strings.TrimSuffix(w, "s")]: - words[i] = strings.TrimSuffix(w, "s") - } - } - if n := len(words); n > 1 { - // Where a secret or an identity is kept is the operator's too: `password-file`, `token-path`. - if (words[n-1] == "file" || words[n-1] == "path") && operatorsOwn[words[n-2]] { - return words[n-2] + "-" + words[n-1] - } - for _, last := range []string{words[n-1], strings.TrimSuffix(words[n-1], "s")} { - if pair := words[n-2] + "-" + last; operatorsCompounds[pair] { - return pair - } - } - } - if last := words[len(words)-1]; operatorsOwn[last] { - return last - } - return "" -} - -// SettingProblems is every way a definition's setting defaults are wrong, in its own words. -func SettingProblems(m Manifest) []string { - if len(m.Settings) == 0 { - return nil - } - used := settingKeysUsedBy(m) - var problems []string - for _, key := range sortedSettingKeys(m.Settings) { - d := m.Settings[key] - say := func(format string, args ...any) { - problems = append(problems, fmt.Sprintf("%s's setting %q: ", m.Module, key)+fmt.Sprintf(format, args...)) - } - if !settingRef.MatchString("${setting:" + key + "}") { - say("not a usable key: lower-case letters, digits, dots, dashes and underscores") - continue - } - if meshWords[key] { - say("the mesh reads %q itself, and a module gives it no default", key) - continue - } - if d.Kind != KindPreference { - say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", d.Kind, KindPreference) - } - if word := operatorsWord(key); word != "" { - say("a key naming %q is the operator's value, and has no default — it is the "+ - "assignment's, never the definition's (novox/hq ADR 0112, ADR 0262)", word) - } - switch v := d.Default.(type) { - case string: - if strings.TrimSpace(v) == "" { - say("an empty default is no default; give the value, or declare nothing") - } - case float64, bool: - case nil: - say("no default: a key without one is the operator's, and is not declared here") - default: - say("a default is a string, a number or a boolean, and this is %T", d.Default) - } - if strings.TrimSpace(d.Why) == "" { - say("no why: say in one sentence why this default") - } - if !used[key] { - say("nothing asks for ${setting:%s}, so the default reaches nothing", key) - } - } - return problems -} - // Defaults is the layer a module's own defaults make, or nothing when it gives none. func Defaults(m Manifest) (Layer, bool) { if len(m.Settings) == 0 { @@ -250,12 +132,3 @@ func Effective(m Manifest, layers []Layer) []SettingSource { sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) return out } - -func sortedSettingKeys(in map[string]SettingDeclaration) []string { - keys := make([]string, 0, len(in)) - for k := range in { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} diff --git a/go/catalogue/setting_defaults_test.go b/go/catalogue/setting_defaults_test.go index a1f013d..7b08f4b 100644 --- a/go/catalogue/setting_defaults_test.go +++ b/go/catalogue/setting_defaults_test.go @@ -26,7 +26,7 @@ func parsed(t *testing.T, m Manifest) error { if err != nil { t.Fatal(err) } - _, err = ParseManifest(raw) + _, err = decodeForTest(raw) return err } @@ -135,40 +135,6 @@ func TestAnOperatorsValueWithoutADefaultIsStillRefused(t *testing.T) { } } -func TestTheParserTakesAPreferenceAndRefusesTheRest(t *testing.T) { - if err := parsed(t, notifier()); err != nil { - t.Fatalf("a preference with a default was refused: %v", err) - } - for _, c := range []struct { - name string - key string - d SettingDeclaration - refuse string - }{ - {"no kind", "font-size", SettingDeclaration{Default: float64(10), Why: "x"}, `only a "preference" has a default`}, - {"another kind", "font-size", SettingDeclaration{Kind: "operator", Default: float64(10), Why: "x"}, `only a "preference"`}, - {"no default", "font-size", SettingDeclaration{Kind: KindPreference, Why: "x"}, "no default"}, - {"an empty default", "font-size", SettingDeclaration{Kind: KindPreference, Default: " ", Why: "x"}, "an empty default"}, - {"an object", "font-size", SettingDeclaration{Kind: KindPreference, Default: map[string]any{"a": 1.0}, Why: "x"}, "a string, a number or a boolean"}, - {"no why", "font-size", SettingDeclaration{Kind: KindPreference, Default: float64(10)}, "no why"}, - {"the operator's", "mail-domain", SettingDeclaration{Kind: KindPreference, Default: "example.tld", Why: "x"}, "is the operator's value"}, - {"a secret", "api-token", SettingDeclaration{Kind: KindPreference, Default: "x", Why: "x"}, "is the operator's value"}, - {"the mesh's word", PortsSetting, SettingDeclaration{Kind: KindPreference, Default: float64(1), Why: "x"}, "the mesh reads"}, - {"read by nothing", "height", SettingDeclaration{Kind: KindPreference, Default: float64(300), Why: "x"}, "reaches nothing"}, - } { - m := notifier() - m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:" + c.key + "}\n" - if c.name == "read by nothing" { - m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n" - } - m.Settings[c.key] = c.d - err := parsed(t, m) - if err == nil || !strings.Contains(err.Error(), c.refuse) { - t.Errorf("%s: parsed %v, want %q", c.name, err, c.refuse) - } - } -} - func TestEveryValueSaysWhereItCameFrom(t *testing.T) { m := notifier() m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:position}\n" @@ -218,60 +184,6 @@ func TestANodeCalledDefaultIsANodesLayer(t *testing.T) { } } -// The operator's own value is told by what a key's name is about: its last word, or its last two as -// a known compound; a plural as its singular; and never a number or a switch. -func TestAKeyIsTheOperatorsByWhatItIsAbout(t *testing.T) { - for _, key := range []string{"max-tokens", "show-hostname", "ghost-opacity", "users-per-page", "mailbox-size", - "font-size", "width", "keyboard-delay", "ipv6-preferred", "client-width", "user-agent", "url-timeout", - "site-title", "cert-renewal-days", "name", "font-name", "allow-resize", "use-gpu", "disable-sender-check", - "max-recipients", "gateway-timeout", "sender-delay", "upstream-resolvers", "mirror-countries", - "pool-region", "proxy-timeout", "listen-backlog", "peer-keepalive", "admin-theme", "log-file", - "cache-path"} { - if w := operatorsWord(key); w != "" { - t.Errorf("%s read as the operator's (%s)", key, w) - } - } - for key, word := range map[string]string{"mail-domain": "domain", "api-key": "key", "admin-password": "password", - "oauth-client-id": "client-id", "db-dsn": "dsn", "public-ip": "ip", "puid": "puid", "dns-zone": "zone", - "webhook": "webhook", "notify_phone": "phone", "cors.origin": "origin", "smtp-pass": "pass", - "backup-passphrase": "passphrase", "data-owner": "owner", "fqdn": "fqdn", "tenant": "tenant", "host": "host", - "allowed-hosts": "host", "admin-emails": "email", "tokens": "token", "hostname": "hostname", - "apikey": "apikey", "servername": "servername", "tls-cert": "cert", "site": "site", "timezone": "timezone", - "bearer": "bearer", "bind-ipv4": "ipv4", "listen-ipv6": "ipv6", "site-name": "site-name", - "server-name": "server-name", "public-name": "public-name", "smtp-relay": "smtp-relay", - "host-name": "host-name", "user-name": "user-name", "domain-name": "domain-name", - "nameserver": "nameserver", "upstream-nameservers": "nameserver", "dns-server": "dns-server", - "dns-servers": "dns-server", "default-gateway": "gateway", "lan-subnet": "subnet", "sender": "sender", - "notify-recipients": "recipient", "contact": "contact", "tls-certificate": "certificate", - "allow-from": "allow-from", "deny-from": "deny-from", "allow-hosts": "host", "use-host": "host", - "trusted": "trusted", "allow-list": "allow-list", "ip-whitelist": "whitelist", "peer": "peer", - "wireguard-peers": "peer", "bind": "bind", "listen": "listen", "upstream": "upstream", "http-proxy": "proxy", - "trusted-proxies": "proxy", "admin": "admin", "notify-admin": "admin", "wake-mac": "mac", - "password-file": "password-file", "key-file": "key-file", "token-path": "token-path", - "secret-file": "secret-file", "cert-path": "cert-path"} { - if w := operatorsWord(key); w != word { - t.Errorf("%s: read %q, want %q", key, w, word) - } - } -} - -// A default is a value the mesh writes, so the installation check reads it, even under a setting -// called `description` or `why`; a setting's own why is prose and is not read. -func TestTheInstallationCheckReadsADefault(t *testing.T) { - m := notifier() - m.Settings["relay"] = SettingDeclaration{Kind: KindPreference, Default: "relay.acme.be", Why: "as at relay.acme.be"} - m.Settings["description"] = SettingDeclaration{Kind: KindPreference, Default: "notes.acme.be", Why: "x"} - problems := strings.Join(InstallationProblems(m), "; ") - for _, want := range []string{"relay.acme.be at settings.relay.default", "notes.acme.be at settings.description.default"} { - if !strings.Contains(problems, want) { - t.Errorf("not reported: %q in %s", want, problems) - } - } - if strings.Contains(problems, "settings.relay.why") { - t.Errorf("a why was read as a value: %s", problems) - } -} - // A default fills ${setting:…} in what a module contributes and serves, and never replaces a value a // contribution states itself. func TestADefaultFillsAContributionAndAServedFactButNotALiteral(t *testing.T) { @@ -298,52 +210,6 @@ func TestADefaultFillsAContributionAndAServedFactButNotALiteral(t *testing.T) { } } -// A stored manifest with a key this reader does not know, at the top or inside any block, is decoded with the key set -// aside and named, and its module is composed, never left out (novox/hq ADR 0578 §6, which changes ADR 0262); the judge -// refuses it. One case per block whose decoder wraps the decoder's words in its own. -func TestAStoredManifestWithAnUnknownKeyIsSetAsideAndTheJudgeRefusesIt(t *testing.T) { - t.Run("it still provides what it provides", aModuleWithAFieldSetAsideStillProvidesWhatItProvides) - t.Run("it is composed whole", aModuleWithAFieldSetAsideIsComposedWhole) - for where, raw := range map[string]string{ - "the top": `{"module": "later", "version": "2", "a-field-from-later": {"x": 1}}`, - "a state": `{"module": "later", "version": "2", "state": [{"name": "s", "a-field-from-later": 1}]}`, - "a provided name": `{"module": "later", "version": "2", "provides": [{"name": "p", "a-field-from-later": 1}]}`, - "an offer's identity": `{"module": "later", "version": "2", "provides": [{"name": "p", "identity": {"in": "x", "a-field-from-later": 1}}]}`, - "a backup": `{"module": "later", "version": "2", "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "y", "a-field-from-later": 1}}]}}`, - "an own secret": `{"module": "later", "version": "2", "own-secrets": {"s": {"path": "/x", "a-field-from-later": 1}}}`, - "a seat's verb": `{"module": "later", "version": "2", "seats": [{"name": "later-seat", "serves": [{"name": "v", "a-field-from-later": 1}]}]}`, - } { - m, err := Decode([]byte(raw)) - if err != nil { - t.Errorf("%s: the stored manifest was not read: %v", where, err) - continue - } - if m.Module != "later" || m.Version != "2" || !strings.Contains(m.SetAside(), `"a-field-from-later"`) || m.ReadNoFurther() { - t.Errorf("%s: read as %q %q, unknown %q", where, m.Module, m.Version, m.UnknownField()) - continue - } - if where != "the top" && !strings.Contains(m.UnknownField(), ": json: unknown field") { - t.Errorf("%s: the block's decoder did not say it: %q", where, m.UnknownField()) - } - left := Resolution{Node: "laptop", Modules: []Manifest{m, notifier()}}.LeftOut(nil, false) - if len(left) != 0 { - t.Errorf("%s: a module was left out for a field set aside: %v", where, left) - } - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "a-field-from-later") { - t.Errorf("%s: the module check took it: %v", where, err) - } - } - var known Manifest - if err := json.Unmarshal([]byte(`{"module": "now", "state": [{"name": "s"}]}`), &known); err != nil || known.UnknownField() != "" { - t.Fatalf("a known manifest: %v %q", err, known.UnknownField()) - } - // A malformed manifest is still refused: only an unknown key is read past. - var bad Manifest - if err := json.Unmarshal([]byte(`{"module": "bad", "state": [{"name": 3}]}`), &bad); err == nil { - t.Fatal("a malformed stored manifest was read") - } -} - // A module left out for a key this controller does not know, inside an entry, is read past that key // alone: it still provides what it provides, its other entries are whole, and a key of the same name // that another entry knows is kept (novox/hq ADR 0262). diff --git a/go/catalogue/setting_in_owner_test.go b/go/catalogue/setting_in_owner_test.go index 00affaa..f441f64 100644 --- a/go/catalogue/setting_in_owner_test.go +++ b/go/catalogue/setting_in_owner_test.go @@ -95,52 +95,6 @@ func TestAnOwnerSettingThatIsNoNumberIsRefused(t *testing.T) { } } -// Where the manifest is read: an owner holding a setting is a whole placeholder or a whole number on -// each side of the colon, and every key is one the module asks for elsewhere or declares a default for. -func TestAnOwnerSettingIsJudgedWhereTheManifestIsRead(t *testing.T) { - ok := []string{"${setting:puid}:${setting:pgid}", "${setting:puid}", "${setting:puid}:1000", "1000:${setting:pgid}", - "1000:1000", "media:media"} - bad := []string{ - "${setting:puid}x", - "x${setting:puid}", - "${setting:puid}:${setting:pgid}:1", - "${setting:puid}${setting:pgid}", - "media:${setting:pgid}", - "${setting:puid}:", - "${setting:other}:${setting:pgid}", - } - for _, owner := range append(ok, bad...) { - m := recycler() - m.Resources[0] = copied(m.Resources[0]) - m.Resources[0]["owner"] = owner - err := parsed(t, m) - refused := err != nil && strings.Contains(err.Error(), "owner") - want := false - for _, b := range bad { - want = want || b == owner - } - if refused != want { - t.Errorf("%s: refused %v, want %v (%v)", owner, refused, want, err) - } - } - // A key with a default is declared, though nothing else asks for it. - m := recycler() - m.Resources = m.Resources[:1] - m.Resources[0] = copied(m.Resources[0]) - m.Resources[0]["owner"] = "${setting:workers}" - m.Settings = map[string]SettingDeclaration{"workers": {Kind: KindPreference, Default: float64(4), Why: "a test"}} - if err := parsed(t, m); err != nil && strings.Contains(err.Error(), "owner") { - t.Errorf("a declared key was refused: %v", err) - } - // A file's owner takes no setting: the sweep says so. - f := recycler() - f.Resources[1] = copied(f.Resources[1]) - f.Resources[1]["owner"] = "${setting:puid}" - if err := parsed(t, f); err == nil || !strings.Contains(err.Error(), "not filled in this field") { - t.Errorf("a file's owner setting passed: %v", err) - } -} - // A key a directory's owner asks for is a destination, so setting it is not called stray. func TestASettingAnOwnerAsksForIsNotStray(t *testing.T) { m := recycler() diff --git a/go/catalogue/setting_in_unit_test.go b/go/catalogue/setting_in_unit_test.go index 77197b7..31ba7af 100644 --- a/go/catalogue/setting_in_unit_test.go +++ b/go/catalogue/setting_in_unit_test.go @@ -108,35 +108,6 @@ func TestASettingAUnitAsksForIsNotStrayAndIsJudged(t *testing.T) { } } -// Third review: a setting may name only a template's instance — right after the `@`, before the final -// suffix, and the whole instance — so a value can never make the unit another unit, nor another kind. -// Refused where the manifest is read, near its author. -func TestASettingInAUnitNameIsOnlyATemplatesInstance(t *testing.T) { - ok := []string{"zfs-scrub-weekly@${setting:scrub-pool}.timer", "getty@${setting:tty}.service"} - bad := []string{ - "${setting:unit}", - "${setting:name}.timer", - "zfs-scrub-${setting:cadence}@storage.timer", - "zfs-scrub@${setting:pool}-x.timer", - "zfs-scrub@x-${setting:pool}.timer", - "zfs-scrub@${setting:pool}.${setting:kind}", - "zfs-scrub@${setting:pool}", - "zfs-scrub@${setting:a}${setting:b}.timer", - } - for _, unit := range append(ok, bad...) { - m := Manifest{Module: "zfs", Resources: []map[string]any{{"id": "t", "type": "service", "unit": unit, "state": "stopped"}}} - err := parsed(t, m) - refused := err != nil && strings.Contains(err.Error(), "instance") - want := false - for _, b := range bad { - want = want || b == unit - } - if refused != want { - t.Errorf("%s: refused %v, want %v (%v)", unit, refused, want, err) - } - } -} - func TestAnInstanceValueMayNotLeadWithADashNorBeLong(t *testing.T) { m := scrubber() for _, bad := range []string{"-storage", "--help", strings.Repeat("a", 65)} { diff --git a/go/catalogue/setting_into.go b/go/catalogue/setting_into.go index 3ced407..0ec1d33 100644 --- a/go/catalogue/setting_into.go +++ b/go/catalogue/setting_into.go @@ -119,32 +119,6 @@ func settingInto(resource map[string]any, layers []Layer, module string) error { // a newline that would begin a directive in the unit file the name ends up in — is refused, never written. var unitPart = regexp.MustCompile(`^[A-Za-z0-9:_.][A-Za-z0-9:_.-]{0,63}$`) -// unitInstance is the one place a setting may stand in a unit's name: the whole instance of a template, -// after its `@` and before its suffix — `zfs-scrub-weekly@${setting:scrub-pool}.timer` — so a value can -// make the unit another instance of the same template and nothing else (third review of mesh-catalog #147). -var unitInstance = regexp.MustCompile(`^[A-Za-z0-9:_.-]+@\$\{setting:[a-z0-9][a-z0-9_.-]*\}\.[a-z]+$`) - -// UnitSettingProblems refuses, where a manifest is read, a service whose unit name carries a setting -// anywhere but as a template's whole instance. -func UnitSettingProblems(m Manifest) []string { - var problems []string - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "service" { - continue - } - unit, _ := r["unit"].(string) - if len(settingsUsed(unit)) == 0 && !strings.Contains(unit, "${setting:") { - continue - } - if !unitInstance.MatchString(unit) { - problems = append(problems, fmt.Sprintf("%s: the service %v's unit %q carries a setting outside a template's "+ - "instance; a setting may stand only as the whole instance, after the @ and before the suffix "+ - "(name@${setting:key}.timer)", m.Module, r["id"], unit)) - } - } - return problems -} - // settingIntoUnit fills ${setting:…} in a service resource's unit name: a module that holds the // distribution's timer for the pool the operator names cannot write the pool into its definition // (novox/hq ADR 0112). Refused, with the key and why, when nothing sets it or the value would not make a @@ -194,12 +168,6 @@ func settingIntoUnit(resource map[string]any, layers []Layer, module string) err // contribution or a served fact — or declares a default for, so the owner follows the identity its // process is told it runs as, never a key that reaches nothing else. -// ownerSide is one side of an owner a setting may fill: a whole placeholder or a whole number. -var ownerSide = `(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)` - -// ownerWithSetting is the only shape an owner holding a setting may have: user, or user:group. -var ownerWithSetting = regexp.MustCompile(`^` + ownerSide + `(?::` + ownerSide + `)?$`) - // wholeID is a value as a uid or a gid, and whether it is one: a whole number below the kernel's "no id" // (2^32-1). Uid 0 is one: a key an owner asks for is the controller's terminal's alone (TerminalKeys). func wholeID(v string) (uint64, bool) { @@ -210,49 +178,6 @@ func wholeID(v string) (uint64, bool) { return n, err == nil && n < 1<<32-1 } -// OwnerSettingProblems refuses, where a manifest is read, a directory whose owner carries a setting -// in any shape but a whole placeholder or number on each side of the colon, or names a key the module -// asks for nowhere else and declares no default for. -func OwnerSettingProblems(m Manifest) []string { - var problems []string - asked := settingKeysAskedOutsideOwners(m) - for k := range m.Settings { - asked[k] = true - } - for _, r := range m.Resources { - owner, _ := r["owner"].(string) - if !strings.Contains(owner, "${setting:") { - continue - } - if fmt.Sprint(r["type"]) != "directory" { - // Said by the placeholder sweep: only a directory's owner takes a setting. - continue - } - if !ownerWithSetting.MatchString(owner) { - problems = append(problems, fmt.Sprintf("%s: the directory %v's owner %q carries a setting in a shape "+ - "the mesh does not fill; an owner names the account by number only, each side of the colon a whole "+ - "${setting:key} or a whole number (${setting:puid}:${setting:pgid}) (novox/hq issue 279)", - m.Module, r["id"], owner)) - continue - } - // A template's text is not here yet, so what the module asks for elsewhere cannot be known: WithTemplatesFrom - // parses its result again with every template filled, and this half is judged then (as config.go skips its - // "placed nowhere" rule while m.Templated()). - if m.Templated() { - continue - } - for _, key := range settingsUsed(owner) { - if !asked[key] { - problems = append(problems, fmt.Sprintf("%s: the directory %v's owner says ${setting:%s}, and the "+ - "module asks for %q nowhere else and declares no default for it: an owner follows the identity "+ - "the module's process is told it runs as (its identity file's ${setting:%s}), never a key of its "+ - "own (novox/hq issue 279)", m.Module, r["id"], key, key, key)) - } - } - } - return problems -} - // settingIntoOwner fills ${setting:…} in a directory's owner, refusing — with the key and why, the // resource left as it was — a key nothing sets and a value that is not a whole number. func settingIntoOwner(resource map[string]any, layers []Layer, module string) error { @@ -386,51 +311,12 @@ func settingKeysAskedOutsideOwners(m Manifest) map[string]bool { // element of `groups` is a whole placeholder or a whole number; a placeholder is filled with the number the // config value holds, and the list reaches the node-engine as numbers. -// groupSide is one element of a container's groups: a whole placeholder or a whole number. -var groupSide = regexp.MustCompile(`^(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)$`) - // groupsOf is a container resource's groups as the manifest wrote them (text or numbers), none when it names none. func groupsOf(r map[string]any) []any { groups, _ := r["groups"].([]any) return groups } -// GroupSettingProblems refuses, where a manifest is read, a container whose groups hold anything but whole -// placeholders and whole numbers, or a placeholder whose key the module neither declares nor asks for elsewhere. -func GroupSettingProblems(m Manifest) []string { - var problems []string - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "container" { - continue - } - for _, g := range groupsOf(r) { - s, isText := g.(string) - if !isText { - continue // a number: the declaration judges its value - } - if !groupSide.MatchString(s) { - problems = append(problems, fmt.Sprintf("%s: the container %v's group %q is not a whole "+ - "${config:key} or a whole number: a supplementary group is a number, and nothing else is "+ - "written into it (novox/hq ADR 0480 clause 7)", m.Module, r["id"], s)) - continue - } - if m.Templated() { - continue - } - for _, key := range settingsUsed(s) { - if _, declared := m.Settings[key]; !declared && !m.DeclaresConfig() { - continue // the settings form judges its own keys - } - if _, declared := m.Settings[key]; !declared { - problems = append(problems, fmt.Sprintf("%s: the container %v's group says ${config:%s}, and the module "+ - "declares no such value (novox/hq ADR 0348 §4)", m.Module, r["id"], key)) - } - } - } - } - return problems -} - // settingIntoGroups fills ${setting:…} in a container's groups, refusing — with the key and why, the resource left as // it was — a key nothing sets and a value that is not a whole number above zero. A number is kept; the list that // results is numbers alone. diff --git a/go/catalogue/setting_takes.go b/go/catalogue/setting_takes.go index 89d8f53..e62ec41 100644 --- a/go/catalogue/setting_takes.go +++ b/go/catalogue/setting_takes.go @@ -153,48 +153,6 @@ func namesItsKeys(m Manifest) bool { return merging > 0 } -// TakenSettingsProblems is every way a definition's takes-settings is wrong (ADR 0326 §2 and §4), in -// its own words: on a file that does not merge, not a list of keys, a key named twice, one the mesh -// reads itself, one the file keeps for itself, or one mergeable file naming its keys beside another -// that names none. -func TakenSettingsProblems(m Manifest) []string { - return takenSettingsProblems(m) -} - -// WrittenSettingsProblems is ADR 0326 §3, judged only where a definition is written — the module -// check, `module add`, a build taken in, the builder and the merge gate's registration — and never -// where a stored one is read again (ParseManifest, a build's manifest at a commit, a module put back): -// a module that declares a setting names the keys of every mergeable file it owns (novox/hq issue -// 574, ADR 0164 §11 and §15). A build stored before the rule, such as betterbird's before its accounts -// file named its keys, must still compose and still be put back; only its next change is held to it. -// -// Said only when none of the module's mergeable files names its keys: when one does, §2 already -// names every file that does not, and a file is not reported twice. -func WrittenSettingsProblems(m Manifest) []string { - if len(m.Settings) == 0 || m.DeclaresConfig() { - // A module in the config form is held to this where it is read, in its own words (ConfigProblems). - return nil - } - var silent []string - for _, r := range m.Resources { - how, _ := r["merge"].(string) - if how == "" { - continue - } - if _, said := r[TakesSettingsField]; said { - return nil - } - silent = append(silent, fmt.Sprint(r["id"])) - } - var problems []string - for _, id := range silent { - problems = append(problems, fmt.Sprintf("%s declares settings, and its mergeable %s names no %s: "+ - "a module that declares a setting names the keys each mergeable file takes, an empty list "+ - "for none (novox/hq ADR 0326 §3, ADR 0164 §11)", m.Module, id, TakesSettingsField)) - } - return problems -} - // takenSettingsProblems is every rule of ADR 0326 §2 and §4, judged where a definition is checked and // where a stored one is composed. func takenSettingsProblems(m Manifest) []string { diff --git a/go/catalogue/setting_takes_test.go b/go/catalogue/setting_takes_test.go index 3deb6a6..c392906 100644 --- a/go/catalogue/setting_takes_test.go +++ b/go/catalogue/setting_takes_test.go @@ -112,80 +112,6 @@ func TestAKeyNoFileNamesIsStray(t *testing.T) { } } -func TestOneNamingFileMakesEveryMergeableFileName(t *testing.T) { - // ADR 0326 §2: a module with one naming and one silent mergeable file is refused, naming the - // silent one — the silent one would otherwise still take every key. - m := twoFiles([]any{"server"}, nil) - problems := TakenSettingsProblems(m) - if len(problems) != 1 || !strings.Contains(problems[0], "sidecar merges and names no takes-settings") { - t.Fatalf("problems: %v", problems) - } - if err := JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "sidecar") { - t.Errorf("composing it should refuse it too, and said: %v", err) - } - raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources}) - if err != nil { - t.Fatal(err) - } - if _, err := ParseManifest(raw); err == nil || !strings.Contains(err.Error(), "sidecar merges and names no") { - t.Errorf("the module check does not say it: %v", err) - } -} - -func TestTakesSettingsIsRefusedWhereItCannotMeanAnything(t *testing.T) { - // ADR 0326 §4. - cases := map[string]struct { - resource map[string]any - says string - }{ - "on a file that does not merge": { - map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x", - TakesSettingsField: []any{"a"}}, - "does not merge"}, - "not a list": { - map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", - TakesSettingsField: "server"}, - "is a list of setting keys"}, - "a mesh word": { - map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", - TakesSettingsField: []any{"endpoints"}}, - "the mesh reads"}, - "a protected key": { - map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", - "protected": []any{"secret"}, TakesSettingsField: []any{"secret"}}, - "keeps it as protected"}, - "named twice": { - map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", - TakesSettingsField: []any{"a", "a"}}, - "twice"}, - "not a key": { - map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", - TakesSettingsField: []any{"two words"}}, - "not a usable setting key"}, - } - for name, c := range cases { - problems := TakenSettingsProblems(Manifest{Module: "m", Resources: []map[string]any{c.resource}}) - if !containsText(problems, c.says) { - t.Errorf("%s: want a problem saying %q, got %v", name, c.says, problems) - } - } - // And a file that does not merge is refused where it is settled, not handed to the host. - plain := map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x", - TakesSettingsField: []any{"a"}} - if _, err := ApplySettings(plain, nil); err == nil { - t.Error("a file that does not merge and names keys was settled") - } -} - -func TestASoftwareSpelledKeyIsUsable(t *testing.T) { - // A file's keys are its software's: nodered's timeZone is not lower case and is still a key. - m := Manifest{Module: "flows", Resources: []map[string]any{{"id": "settings", "type": "file", - "path": "/x", "merge": MergeJSON, "content": "{}", TakesSettingsField: []any{"timeZone"}}}} - if problems := TakenSettingsProblems(m); len(problems) != 0 { - t.Errorf("timeZone was refused: %v", problems) - } -} - func containsText(list []string, text string) bool { for _, s := range list { if strings.Contains(s, text) { @@ -194,50 +120,3 @@ func containsText(list []string, text string) bool { } return false } - -func TestAModuleThatDeclaresASettingNamesItsFilesKeys(t *testing.T) { - // ADR 0326 §3, ADR 0164 §15, novox/hq issue 574: only a module that declares no settings keeps - // today's form. One that declares a preference and owns a silent mergeable file is refused where - // a definition is written — and read and composed where a stored one is read again, so a build kept - // from before the rule is still sent and still put back. - declaring := func(app, sidecar []any) Manifest { - m := twoFiles(app, sidecar) - m.Resources[2]["content"] = "WORKERS=${setting:workers}\n" - m.Settings = map[string]SettingDeclaration{"workers": {Kind: KindPreference, Default: 4.0, Why: "a number"}} - return m - } - m := declaring(nil, nil) - problems := WrittenSettingsProblems(m) - if len(problems) != 2 || !containsText(problems, "mergeable app names no takes-settings") || - !containsText(problems, "mergeable sidecar names no takes-settings") { - t.Errorf("where it is written: %v", problems) - } - if problems := TakenSettingsProblems(m); len(problems) != 0 { - t.Errorf("what a stored definition is read by refuses it: %v", problems) - } - raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources, - "settings": map[string]any{"workers": map[string]any{"kind": KindPreference, "default": 4, "why": "a number"}}}) - if err != nil { - t.Fatal(err) - } - read, err := ParseManifest(raw) - if err != nil { - t.Fatalf("a stored build from before the rule is not read again: %v", err) - } - if err := JudgeSettings(read, nil, false); err != nil { - t.Errorf("a stored definition is composed as before, and was refused: %v", err) - } - - if problems := WrittenSettingsProblems(declaring([]any{"server"}, []any{})); len(problems) != 0 { - t.Errorf("every mergeable file names its keys, and was refused: %v", problems) - } - if problems := WrittenSettingsProblems(twoFiles(nil, nil)); len(problems) != 0 { - t.Errorf("a module declaring no setting was refused: %v", problems) - } - // One naming file and one silent: §2 names the silent one, and §3 does not name it again. - mixed := declaring([]any{"server"}, nil) - if written, taken := WrittenSettingsProblems(mixed), TakenSettingsProblems(mixed); len(written) != 0 || len(taken) != 1 { - t.Errorf("the silent file is reported %d times by §3 and %d by §2; want once, by §2: %v %v", - len(written), len(taken), written, taken) - } -} diff --git a/go/catalogue/several_contributions_test.go b/go/catalogue/several_contributions_test.go index b0fe881..1884ef2 100644 --- a/go/catalogue/several_contributions_test.go +++ b/go/catalogue/several_contributions_test.go @@ -15,7 +15,7 @@ const twoRoutes = `{"module":"minio","version":"1","requires":["route"], "contributes":{"route":{"api":{"label":"files-api","port":9000},"console":{"label":"files","port":9001}}}}` func TestContributesReadsBothShapesAndWritesThemBack(t *testing.T) { - m, err := ParseManifest([]byte(twoRoutes)) + m, err := decodeForTest([]byte(twoRoutes)) if err != nil { t.Fatal(err) } @@ -23,7 +23,7 @@ func TestContributesReadsBothShapesAndWritesThemBack(t *testing.T) { if len(locals) != 2 || locals["api"]["label"] != "files-api" || locals["console"]["port"] != float64(9001) { t.Fatalf("two contributions under local names: %+v", locals) } - plain, err := ParseManifest([]byte(`{"module":"board","version":"1","requires":["route"], + plain, err := decodeForTest([]byte(`{"module":"board","version":"1","requires":["route"], "contributes":{"route":{"label":"board","port":8080}}}`)) if err != nil { t.Fatal(err) @@ -36,7 +36,7 @@ func TestContributesReadsBothShapesAndWritesThemBack(t *testing.T) { if err != nil { t.Fatal(err) } - again, err := ParseManifest(raw) + again, err := decodeForTest(raw) if err != nil { t.Fatalf("what was written does not read: %v\n%s", err, raw) } @@ -45,21 +45,6 @@ func TestContributesReadsBothShapesAndWritesThemBack(t *testing.T) { } } -func TestAContributionLocalNameMustBeUsable(t *testing.T) { - for _, bad := range []string{ - // Not a usable name. - `{"module":"minio","version":"1","requires":["route"], - "contributes":{"route":{"Not OK":{"label":"files","port":9000}}}}`, - // A local contribution with nothing in it. - `{"module":"minio","version":"1","requires":["route"], - "contributes":{"route":{"api":{}}}}`, - } { - if _, err := ParseManifest([]byte(bad)); err == nil { - t.Errorf("accepted:\n%s", bad) - } - } -} - func minimalRouteProxy() Manifest { return Manifest{Module: "route-proxy", Version: "1", Provides: FromAnywhere("route"), @@ -68,7 +53,7 @@ func minimalRouteProxy() Manifest { } func TestAModuleWithTwoRoutesGivesTheProviderTwoContributions(t *testing.T) { - minio, err := ParseManifest([]byte(twoRoutes)) + minio, err := decodeForTest([]byte(twoRoutes)) if err != nil { t.Fatal(err) } @@ -131,7 +116,7 @@ func TestASingleRouteStillResolvesTheOrdinaryWay(t *testing.T) { // exactly the duplicate a live plan against minio surfaced (files-api appearing once with a // credential, once without, while files got neither). func TestContributionsFromHasNoSingleValueWhenAModuleContributesSeveralTimes(t *testing.T) { - minio, err := ParseManifest([]byte(twoRoutes)) + minio, err := decodeForTest([]byte(twoRoutes)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/several_secrets_test.go b/go/catalogue/several_secrets_test.go index e074898..35c2c7e 100644 --- a/go/catalogue/several_secrets_test.go +++ b/go/catalogue/several_secrets_test.go @@ -15,7 +15,7 @@ const twoSecrets = `{"module":"ca","version":"1","requires":["secret"], "resources":[{"id":"state","type":"directory","path":"/var/lib/ca","mode":"0700"}]}` func TestSecretsReadBothShapesAndWriteThemBack(t *testing.T) { - m, err := ParseManifest([]byte(twoSecrets)) + m, err := decodeForTest([]byte(twoSecrets)) if err != nil { t.Fatal(err) } @@ -23,7 +23,7 @@ func TestSecretsReadBothShapesAndWriteThemBack(t *testing.T) { if len(files) != 2 || files[0].Local != "root-key" || files[1].Path != "/var/lib/ca/root.pass" { t.Fatalf("two files under local names, in order: %+v", files) } - plain, err := ParseManifest([]byte(`{"module":"app","version":"1","requires":["secret"],"secrets":{"secret":"/var/lib/app/secret"}}`)) + plain, err := decodeForTest([]byte(`{"module":"app","version":"1","requires":["secret"],"secrets":{"secret":"/var/lib/app/secret"}}`)) if err != nil { t.Fatal(err) } @@ -35,7 +35,7 @@ func TestSecretsReadBothShapesAndWriteThemBack(t *testing.T) { if err != nil { t.Fatal(err) } - again, err := ParseManifest(raw) + again, err := decodeForTest(raw) if err != nil { t.Fatalf("what was written does not read: %v\n%s", err, raw) } @@ -44,27 +44,8 @@ func TestSecretsReadBothShapesAndWriteThemBack(t *testing.T) { } } -func TestALocalNameMayNotCollideWithWhatTheModuleAlreadyCallsSomething(t *testing.T) { - for _, bad := range []string{ - // One of the module's own secrets. - `{"module":"ca","version":"1","requires":["secret"],"own-secrets":{"root-key":"/var/lib/ca/own"}, - "secrets":{"secret":{"root-key":"/var/lib/ca/root.key"}}}`, - // Something it requires. - `{"module":"ca","version":"1","requires":["secret","postgres-database"], - "secrets":{"secret":{"postgres-database":"/var/lib/ca/x"}}}`, - // Not a usable name. - `{"module":"ca","version":"1","requires":["secret"],"secrets":{"secret":{"Root Key":"/var/lib/ca/x"}}}`, - // A relative path. - `{"module":"ca","version":"1","requires":["secret"],"secrets":{"secret":{"root-key":"root.key"}}}`, - } { - if _, err := ParseManifest([]byte(bad)); err == nil { - t.Errorf("accepted:\n%s", bad) - } - } -} - func vaultAndCA() map[string]Manifest { - ca, _ := ParseManifest([]byte(twoSecrets)) + ca, _ := decodeForTest([]byte(twoSecrets)) vault := Manifest{Module: "mesh-vault", Version: "1", Provides: FromAnywhere("secret"), Grants: map[string]string{"secret": "/var/lib/vault/grants"}, Receives: map[string]string{"secret": "/var/lib/vault/grants/mesh.json"}} @@ -163,7 +144,7 @@ func TestAProviderKeepsOneFilePerHolder(t *testing.T) { // One file under a local name is still a local name (review C1): the need carries it, the file // is written, and ${secret:} is filled. func TestOneLocalNameIsStillALocalName(t *testing.T) { - only, _ := ParseManifest([]byte(`{"module":"one","version":"1","requires":["secret"], + only, _ := decodeForTest([]byte(`{"module":"one","version":"1","requires":["secret"], "secrets":{"secret":{"only":"/var/lib/one/only"}}}`)) vault := vaultAndCA()["mesh-vault"] got, err := Resolve(shelf(vault, only), []string{"mesh-vault", "one"}, workstation(), World{}) @@ -194,12 +175,3 @@ func TestOneLocalNameIsStillALocalName(t *testing.T) { t.Fatal("the file under the one local name was not written") } } - -// A local name names one credential: two requirements may not share it (review C2). -func TestALocalNameIsUniqueAcrossRequirements(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["secret","postgres-database"], - "secrets":{"secret":{"x":"/var/lib/x/a"},"postgres-database":{"x":"/var/lib/x/b"}}}`)) - if err == nil || !strings.Contains(err.Error(), "both under") { - t.Fatalf("two requirements under one local name were accepted: %v", err) - } -} diff --git a/go/catalogue/shared_account_test.go b/go/catalogue/shared_account_test.go index aab013d..a968576 100644 --- a/go/catalogue/shared_account_test.go +++ b/go/catalogue/shared_account_test.go @@ -46,7 +46,7 @@ func TestTwoModulesSettingOneAccountsShellOrHomeAreRefused(t *testing.T) { func TestAnAccountOnlyGivenGroupsKeepsItsWrittenPlace(t *testing.T) { compose := func(raw string) []map[string]any { t.Helper() - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/shared_credential_test.go b/go/catalogue/shared_credential_test.go index 1965fd9..5a5d18e 100644 --- a/go/catalogue/shared_credential_test.go +++ b/go/catalogue/shared_credential_test.go @@ -1,40 +1,9 @@ package catalogue import ( - "strings" "testing" ) -// A provider with one credential shares it (novox/hq ADR 0158): the offer names the own secret, the -// secret says how it is taken, and a consumer's need carries the name so the plan mints its copy -// from the provider's value. -func TestAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"downloader","version":"1", - "own-secrets":{"password":{"path":"/var/lib/mesh/downloader/password","taken":"at-start"}}, - "provides":[{"name":"downloader-api","credential":{"own":"password"}}], - "serves":{"downloader-api":{"port":8080,"username":"admin"}}}`)) - if err != nil { - t.Fatal(err) - } - if own, shared := m.SharedCredentialOf("downloader-api"); !shared || own != "password" { - t.Fatalf("the offer's credential was not read: %v %v", own, shared) - } - if got := m.ProvisionsSharing("password"); len(got) != 1 || got[0] != "downloader-api" { - t.Fatalf("the provisions sharing the secret: %v", got) - } - - for want, raw := range map[string]string{ - "declares no such secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":"password"}}]}`, - "must say how the module takes it": `{"module":"d","version":"1","own-secrets":{"password":"/p"}, - "provides":[{"name":"d-api","credential":{"own":"password"}}]}`, - "names no own secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":""}}]}`, - } { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("expected a refusal saying %q, got %v", want, err) - } - } -} - func sharingShelf() map[string]Manifest { return shelf( Manifest{Module: "downloader", Version: "1", diff --git a/go/catalogue/showcase_manifest_test.go b/go/catalogue/showcase_manifest_test.go index 1ba866c..f210b68 100644 --- a/go/catalogue/showcase_manifest_test.go +++ b/go/catalogue/showcase_manifest_test.go @@ -18,7 +18,7 @@ func TestTheShowcaseModuleIsAValidManifest(t *testing.T) { if err != nil { t.Fatal(err) } - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { t.Fatalf("the module that exercises everything does not parse:\n%v", err) } diff --git a/go/catalogue/state.go b/go/catalogue/state.go index 9229d3d..390dd5c 100644 --- a/go/catalogue/state.go +++ b/go/catalogue/state.go @@ -82,47 +82,3 @@ func ReadState(read string) (module, local string, err error) { } return module, local, nil } - -// StateProblems is what is wrong with a manifest's state and reads. -// -// Refused at registration, because a bucket name the bus cannot hold is a module that installs, -// starts, and is refused on its first write with a reason about a bucket nobody named. -func StateProblems(m Manifest) []string { - var problems []string - if len(m.State) > 0 && !stateName.MatchString(m.Module) { - problems = append(problems, fmt.Sprintf( - "%s keeps state, and a module's name is part of its buckets' names, which take one plain "+ - "name — no dot (novox/hq ADR 0201)", m.Module)) - } - seen := map[string]bool{} - for _, s := range m.State { - switch { - case !stateName.MatchString(s.Name): - problems = append(problems, fmt.Sprintf( - "%s keeps state %q: a state is named locally — lower-case letters, digits and hyphens, "+ - "no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0201)", m.Module, s.Name)) - case seen[s.Name]: - problems = append(problems, fmt.Sprintf("%s keeps state %q twice", m.Module, s.Name)) - } - seen[s.Name] = true - if s.History < 0 || s.History > StateMostHistory { - problems = append(problems, fmt.Sprintf( - "%s keeps %d values of %q; a key keeps between 1 and %d", m.Module, s.History, s.Name, StateMostHistory)) - } - if s.TTLSeconds < 0 { - problems = append(problems, fmt.Sprintf("%s gives %q a negative lifetime", m.Module, s.Name)) - } - } - for _, r := range m.Reads { - module, _, err := ReadState(r) - if err != nil { - problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err)) - continue - } - if module == m.Module { - problems = append(problems, fmt.Sprintf( - "%s reads %q, which is its own state: a module reads and writes what it keeps already", m.Module, r)) - } - } - return problems -} diff --git a/go/catalogue/state_test.go b/go/catalogue/state_test.go index a30ee17..cf6cb7e 100644 --- a/go/catalogue/state_test.go +++ b/go/catalogue/state_test.go @@ -2,14 +2,13 @@ package catalogue import ( "encoding/json" - "strings" "testing" ) // A module declares the state it keeps and the state it reads (novox/hq ADR 0201), a bucket by its // bare name or with the owner's options. func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"claude-code","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"claude-code","version":"1",` + `"state":["servers",{"name":"seen","history":5,"ttl-seconds":3600}],` + `"reads":["licence-manager.bindings"]}`)) if err != nil { @@ -28,59 +27,9 @@ func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) { } } -// A name the bus could not hold, or that would let two modules derive one bucket, is refused at -// registration in the manifest's words. -func TestAStateNameIsLocalAndOneToken(t *testing.T) { - for _, c := range []struct{ manifest, says string }{ - {`{"module":"a","version":"1","state":["mesh.servers"]}`, `keeps state "mesh.servers": a state is named locally`}, - {`{"module":"a","version":"1","state":["my_servers"]}`, `keeps state "my_servers"`}, - {`{"module":"a","version":"1","state":["s","s"]}`, `keeps state "s" twice`}, - {`{"module":"a","version":"1","state":[{"name":"s","history":65}]}`, `a key keeps between 1 and 64`}, - {`{"module":"a.b","version":"1","state":["s"]}`, `no dot`}, - {`{"module":"a","version":"1","reads":["bindings"]}`, `a read is .`}, - {`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`}, - {`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine, key-ttl}`}, - } { - _, err := ParseManifest([]byte(c.manifest)) - if err == nil { - t.Errorf("%s was accepted", c.manifest) - continue - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) - } - } -} - -// **Across the whole catalogue**: every state name is local and every read names a module's state. -func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) { - manifests := theCatalogue(t) - var problems []string - for _, m := range manifests { - problems = append(problems, StateProblems(m)...) - } - if len(problems) > 0 { - t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n ")) - } -} - -// The catalogue pass no longer judges one module's reads against another's manifest (novox/hq ADR 0495 clauses 1 and 5, -// issue 1790): what an owner keeps is what it announces on the bus, and each module's own announcement test holds that. -func TestTheCataloguePassJudgesNoReadAgainstTheOwnersManifest(t *testing.T) { - shelf := Shelf{ - "licence-manager": {Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}}, - "claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}}, - } - for _, p := range CatalogueProblems(shelf) { - if strings.Contains(p, "keeps no state") { - t.Fatalf("the catalogue pass judged a read against the owner's manifest: %s", p) - } - } -} - // 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before. func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` + `"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]}`)) if err != nil { t.Fatal(err) diff --git a/go/catalogue/stream_work_queue_test.go b/go/catalogue/stream_work_queue_test.go index 9f8ccf6..d266c96 100644 --- a/go/catalogue/stream_work_queue_test.go +++ b/go/catalogue/stream_work_queue_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -17,7 +16,7 @@ const mailSendsStream = `[{"name":"mail-sends","events":["mail.send.approved"]," // bus.queues (novox/hq ADR 0436 §6, checked by 1178's WorkQueueStreamProblems), so this stays accepted once // that rule exists. func TestAWorkQueueStreamIsDeclaredAndAccepted(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","streams":` + mailSendsStream + + m, err := decodeForTest([]byte(`{"module":"mesh-mail","version":"1","streams":` + mailSendsStream + `,"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + `{"id":"send","type":"process","run":["./mesh-mail","send"],"user":"mesh-mail-send",` + `"bus":{"queues":["mail-sends"]}}]}`)) @@ -31,37 +30,10 @@ func TestAWorkQueueStreamIsDeclaredAndAccepted(t *testing.T) { } } -// 1177/AC2: a stream whose work-queue values are out of range, contradictory, or carries a grant is refused -// naming the stream and the rule. -func TestAWorkQueueStreamsValuesAreBounded(t *testing.T) { - for name, c := range map[string]struct{ streams, says string }{ - "max-deliveries 0": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + - `"max-deliveries":0,"duplicates":24}]`, "between 1 and 10"}, - "max-deliveries 11": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + - `"max-deliveries":11,"duplicates":24}]`, "between 1 and 10"}, - "duplicates longer than its maximum age": {`[{"name":"mail-sends","events":["mail.send.approved"],` + - `"max-age-days":1,"retention":"work-queue","max-deliveries":5,"duplicates":25}]`, "longer than its maximum age"}, - "an unknown retention": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"sometimes"}]`, - "retention"}, - "max-deliveries without retention work-queue": {`[{"name":"mail-sends","events":["mail.send.approved"],` + - `"max-deliveries":5}]`, "without retention"}, - "duplicates without retention work-queue": {`[{"name":"mail-sends","events":["mail.send.approved"],` + - `"duplicates":24}]`, "without retention"}, - "a work-queue stream carrying a grant": {`[{"name":"mail-sends","events":["mail.send.approved"],` + - `"retention":"work-queue","max-deliveries":5,` + - `"grant":{"config":"grants","events":"mail-sends.{key}.**"}}]`, "carrying a grant"}, - } { - _, err := ParseManifest([]byte(mailManifest(t, c.streams, ""))) - if err == nil || !strings.Contains(err.Error(), "mail-sends") || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming the stream and %q", name, err, c.says) - } - } -} - // 1177/AC3: a stream with no retention composes as main does — limits retention, dropping its oldest — proven // at the broker, where the bus's retention policy is decided (internal/broker/modstreams_test.go). func TestAStreamWithNoRetentionReadsAsLimits(t *testing.T) { - m, err := ParseManifest([]byte(mailManifest(t, mailStream, ""))) + m, err := decodeForTest([]byte(mailManifest(t, mailStream, ""))) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/streams.go b/go/catalogue/streams.go index 40a4ff4..2aa8c9b 100644 --- a/go/catalogue/streams.go +++ b/go/catalogue/streams.go @@ -143,155 +143,6 @@ func (s StreamDeclaration) Carries(pattern string) bool { return false } -// StreamProblems is what is wrong with a manifest's streams, judged on the one manifest. -func StreamProblems(m Manifest) []string { - if len(m.Streams) == 0 { - return nil - } - var problems []string - if !stateName.MatchString(m.Module) { - problems = append(problems, fmt.Sprintf( - "%s declares a stream, and a module's name is part of its stream's subjects, which take one plain "+ - "name — no dot (novox/hq ADR 0363)", m.Module)) - } - seen := map[string]bool{} - terminal := TerminalKeys(m) - for i, s := range m.Streams { - switch { - case !stateName.MatchString(s.Name): - problems = append(problems, fmt.Sprintf( - "%s declares stream %q: a stream is named locally — lower-case letters, digits and hyphens "+ - "(novox/hq ADR 0363)", m.Module, s.Name)) - case seen[s.BusName()]: - problems = append(problems, fmt.Sprintf("%s declares stream %q twice", m.Module, s.Name)) - case slices.Contains(MeshStreamNames, s.BusName()): - problems = append(problems, fmt.Sprintf( - "%s declares stream %q, which is the mesh's own stream %s (novox/hq ADR 0363)", m.Module, s.Name, s.BusName())) - default: - for _, prefix := range ReservedStreamPrefixes { - if strings.HasPrefix(s.BusName(), prefix) { - problems = append(problems, fmt.Sprintf( - "%s declares stream %q, whose name on the bus starts with %s, which the mesh derives names "+ - "with (novox/hq ADR 0363)", m.Module, s.Name, prefix)) - } - } - } - seen[s.BusName()] = true - if s.MaxAgeDays != 0 && (s.MaxAgeDays < StreamLeastDays || s.MaxAgeDays > StreamMostDays) { - problems = append(problems, fmt.Sprintf("%s keeps stream %q %d days; a stream keeps between %d and %d "+ - "(novox/hq ADR 0363)", m.Module, s.Name, s.MaxAgeDays, StreamLeastDays, StreamMostDays)) - } - if s.MaxMiB != 0 && (s.MaxMiB < StreamLeastMiB || s.MaxMiB > StreamMostMiB) { - problems = append(problems, fmt.Sprintf("%s caps stream %q at %d MiB; a stream holds between %d and %d "+ - "(novox/hq ADR 0363)", m.Module, s.Name, s.MaxMiB, StreamLeastMiB, StreamMostMiB)) - } - // A stream may declare retention: work-queue (novox/hq ADR 0436 §6): a message removed once a - // consumer acknowledges it, the bus refusing a new one when full rather than dropping its oldest, with - // max-deliveries (1 to 10) and a duplicate window (duplicates, hours, at most its maximum age) -- both - // meaningless, and refused, without it. A work-queue stream is read by its one consumer, never granted. - switch s.Retention { - case "", StreamRetentionWorkQueue: - default: - problems = append(problems, fmt.Sprintf( - "%s's stream %q declares retention %q; a stream's retention is %q or nothing (novox/hq ADR 0436 §6)", - m.Module, s.Name, s.Retention, StreamRetentionWorkQueue)) - } - switch { - case s.Retention == StreamRetentionWorkQueue && - (s.MaxDeliveries < StreamLeastDeliveries || s.MaxDeliveries > StreamMostDeliveries): - problems = append(problems, fmt.Sprintf( - "%s's stream %q declares max-deliveries %d; a work-queue stream's max-deliveries is between %d "+ - "and %d (novox/hq ADR 0436 §6)", m.Module, s.Name, s.MaxDeliveries, StreamLeastDeliveries, - StreamMostDeliveries)) - case s.Retention != StreamRetentionWorkQueue && s.MaxDeliveries != 0: - problems = append(problems, fmt.Sprintf( - "%s's stream %q declares max-deliveries %d without retention %q (novox/hq ADR 0436 §6)", - m.Module, s.Name, s.MaxDeliveries, StreamRetentionWorkQueue)) - } - switch { - case s.Duplicates != 0 && s.Retention != StreamRetentionWorkQueue: - problems = append(problems, fmt.Sprintf( - "%s's stream %q declares duplicates %d hours without retention %q (novox/hq ADR 0436 §6)", - m.Module, s.Name, s.Duplicates, StreamRetentionWorkQueue)) - case s.Duplicates != 0 && s.Duplicates > s.Days()*24: - problems = append(problems, fmt.Sprintf( - "%s's stream %q keeps duplicates %d hours, longer than its maximum age of %d days (novox/hq "+ - "ADR 0436 §6)", m.Module, s.Name, s.Duplicates, s.Days())) - } - if s.Retention == StreamRetentionWorkQueue && s.Grant != nil { - problems = append(problems, fmt.Sprintf( - "%s's stream %q declares retention %q carrying a grant; a work-queue stream is read by its "+ - "module's one consumer, never granted (novox/hq ADR 0436 §6)", m.Module, s.Name, s.Retention)) - } - if len(s.Events) == 0 { - problems = append(problems, fmt.Sprintf("%s declares stream %q with no events", m.Module, s.Name)) - } - for _, e := range s.Events { - if err := eventPattern(e, false); err != nil { - problems = append(problems, fmt.Sprintf("%s's stream %q carries %q: %v", m.Module, s.Name, e, err)) - } - } - // **An event goes to one place** (ADR 0363 §2): an emitted event a stream carries, or two streams - // carrying one event, would leave the module's runtime to choose. - for _, e := range m.EmitsAll() { - if s.Carries(e) { - problems = append(problems, fmt.Sprintf( - "%s emits %q, which its stream %q carries: an event goes to EVENTS or to the module's stream, "+ - "so name it in one place (novox/hq ADR 0363)", m.Module, e, s.Name)) - } - } - for _, other := range m.Streams[i+1:] { - for _, a := range s.Events { - for _, b := range other.Events { - if patternsOverlap(a, b) { - problems = append(problems, fmt.Sprintf( - "%s's streams %q and %q both carry %q and %q: an event goes to one stream (novox/hq "+ - "ADR 0363)", m.Module, s.Name, other.Name, a, b)) - } - } - } - } - if g := s.Grant; g != nil { - if !slices.Contains(terminal, g.Config) { - problems = append(problems, fmt.Sprintf( - "%s's stream %q is granted by %q, which is not a trusted config value of the module, set only at "+ - "the controller's terminal or on the operator's Approve: a grant of what a module hears is the "+ - "operator's (novox/hq ADR 0348 §8, ADR 0363)", m.Module, s.Name, g.Config)) - } - if strings.Count(g.Events, GrantKey) != 1 { - problems = append(problems, fmt.Sprintf( - "%s's stream %q grants %q, which must hold %s exactly once, as a name of its own (novox/hq ADR 0363)", - m.Module, s.Name, g.Events, GrantKey)) - } else if err := eventPattern(g.Events, true); err != nil { - problems = append(problems, fmt.Sprintf("%s's stream %q grants %q: %v", m.Module, s.Name, g.Events, err)) - } - } - } - return problems -} - -// eventPattern checks one local event pattern: names, `*` for one, `**` last for the rest; with key, -// GrantKey is a name too. -func eventPattern(pattern string, key bool) error { - if pattern == "" { - return fmt.Errorf("a pattern needs a name") - } - parts := strings.Split(pattern, ".") - for i, part := range parts { - switch { - case part == TheRest: - if i != len(parts)-1 { - return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest) - } - case part == OneName: - case key && part == GrantKey: - case !eventName.MatchString(part): - return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part) - } - } - return nil -} - // StreamsHearing is every stream of a module that carries some of a consumed pattern's events, and // whether one of them carries all of them. func (m Manifest) StreamsHearing(pattern string) (hearing []StreamDeclaration, whole bool) { @@ -351,119 +202,6 @@ func patternsOverlap(a, b string) bool { } } -// WorkQueueStreamProblems refuses a work-queue stream (novox/hq ADR 0436 §6) that is not read by exactly -// one process of its own module: named by none, it queues forever unconsumed; named by two, each would take -// half of what the other needed, silently. -func WorkQueueStreamProblems(m Manifest) []string { - var problems []string - named := map[string]int{} - for _, b := range m.ProcessBuses() { - for _, q := range b.Queues { - named[q]++ - } - } - for _, s := range m.Streams { - if s.Retention != StreamRetentionWorkQueue { - continue - } - switch named[s.Name] { - case 0: - problems = append(problems, fmt.Sprintf( - "%s's work-queue stream %q: no process names it in bus.queues; a work-queue stream is read by "+ - "exactly one process (novox/hq ADR 0436 §6)", m.Module, s.Name)) - case 1: - default: - problems = append(problems, fmt.Sprintf( - "%s's work-queue stream %q: %d processes name it in bus.queues; a work-queue stream is read by "+ - "exactly one (novox/hq ADR 0436 §6)", m.Module, s.Name, named[s.Name])) - } - } - return problems -} - -// WorkQueueConsumedElsewhereProblems refuses another module's `consumes` naming a work-queue stream's events -// (novox/hq ADR 0436 §6): read by its module's one process alone, never broadcast to a consumer that only -// subscribes. Modelled on RestrictedHearingProblems. -func WorkQueueConsumedElsewhereProblems(shelf Shelf) []string { - var problems []string - for _, module := range shelfOrder(shelf) { - m := shelf[module] - for _, c := range m.Consumes { - emitter, rest, named := strings.Cut(c, ".") - provider, known := shelf[emitter] - if !named || emitter == OneName || emitter == TheRest || !known || emitter == module { - continue - } - hearing, _ := provider.StreamsHearing(rest) - for _, s := range hearing { - if s.Retention != StreamRetentionWorkQueue { - continue - } - problems = append(problems, fmt.Sprintf( - "%s consumes %s, which %s's work-queue stream %s carries; a work-queue stream is read by "+ - "its module's one process alone, never by another module's consumes (novox/hq ADR 0436 "+ - "§6)", module, c, emitter, s.BusName())) - } - } - } - return problems -} - -// StreamNameProblems are the rules about stream names no single manifest can be judged against: two -// modules declaring one name. -func StreamNameProblems(shelf Shelf) []string { - var problems []string - declaredBy := map[string]string{} - for _, module := range shelfOrder(shelf) { - for _, s := range shelf[module].Streams { - name := s.BusName() - if first, taken := declaredBy[name]; taken && first != module { - problems = append(problems, fmt.Sprintf( - "%s declares the stream %s, which %s already declares; a stream has one owner (novox/hq ADR 0363)", - module, name, first)) - continue - } - declaredBy[name] = module - } - } - return problems -} - -// RestrictedHearingProblems refuses a module that would hear a restricted stream without an account of its own -// (novox/hq ADR 0376 §2): a `consumes` naming a module literally and asking for events a restricted stream of -// that module carries, by a manifest that declares no `runs-as`. A module the machine's tool runner carries -// reaches the bus as the tool runner's account, which every agent session there shares, so the controller would -// compose nothing for it. Judged only where the provider is on the shelf, as a consumer may be installed before -// its emitter; a wildcard emitter never reaches a module's stream (ADR 0363 §5) and is not judged. -func RestrictedHearingProblems(shelf Shelf) []string { - var problems []string - for _, module := range shelfOrder(shelf) { - m := shelf[module] - if m.RunsAs != "" { - continue - } - for _, c := range m.Consumes { - emitter, rest, named := strings.Cut(c, ".") - provider, known := shelf[emitter] - if !named || emitter == OneName || emitter == TheRest || !known { - continue - } - hearing, _ := provider.StreamsHearing(rest) - for _, s := range hearing { - if s.Grant == nil { - continue - } - problems = append(problems, fmt.Sprintf( - "%s consumes %s, which %s's restricted stream %s carries, so %s must run as an account of its "+ - "own (runs-as): the machine's runtime runs as the operator's account, which every agent "+ - "runs as, and never hears a restricted stream (novox/hq ADR 0376)", - module, c, emitter, s.BusName(), module)) - } - } - } - return problems -} - // GrantedKeys reads a restricting config value (novox/hq ADR 0363 §4): each key whose list names the // module wherever it runs, or the module on this machine as `.`. A value of another shape // grants nothing; a key that is not one usable name grants nothing. @@ -646,15 +384,3 @@ var StreamsKeptUntilMoved = map[string][]string{ "mesh-delivery-checker": {"checks"}, "mesh-delivery-forge-bridge": {"forge"}, } - -// PredefinedFormProblems refuses each stream a module declares that StreamsKeptUntilMoved does not name for it. -func PredefinedFormProblems(m Manifest) []string { - var out []string - for _, s := range m.Streams { - if !slices.Contains(StreamsKeptUntilMoved[m.Module], s.Name) { - out = append(out, fmt.Sprintf("%s keeps the stream %q, outside the bus's predefined forms: keep it as a state, "+ - "a log or an object store, which the runtime reads for the module's tools and others read by `reads`", m.Module, s.Name)) - } - } - return out -} diff --git a/go/catalogue/streams_test.go b/go/catalogue/streams_test.go index cb02362..6a5cd52 100644 --- a/go/catalogue/streams_test.go +++ b/go/catalogue/streams_test.go @@ -3,7 +3,6 @@ package catalogue import ( "encoding/json" "slices" - "strings" "testing" ) @@ -23,7 +22,7 @@ const mailStream = `[{"name":"mail","events":["mail.*.message.added","mail.*.mes // A module declares a stream of its own (ADR 0363 §1): its events with a variable name (§3), its age and // size with defaults, and a grant by a trusted setting of its own (§4). func TestAModuleDeclaresAStreamOfItsOwn(t *testing.T) { - m, err := ParseManifest([]byte(mailManifest(t, mailStream, ""))) + m, err := decodeForTest([]byte(mailManifest(t, mailStream, ""))) if err != nil { t.Fatal(err) } @@ -38,7 +37,7 @@ func TestAModuleDeclaresAStreamOfItsOwn(t *testing.T) { if !s.Carries("mail.work.message.added") || s.Carries("mail.work.body") || !s.Carries("mail.*.message.removed") { t.Fatal("the stream's patterns do not say what it carries") } - plain, err := ParseManifest([]byte(`{"module":"feed","version":"1","streams":[{"name":"feed-items","events":["item.**"]}]}`)) + plain, err := decodeForTest([]byte(`{"module":"feed","version":"1","streams":[{"name":"feed-items","events":["item.**"]}]}`)) if err != nil { t.Fatalf("a stream without a grant was refused: %v", err) } @@ -47,65 +46,6 @@ func TestAModuleDeclaresAStreamOfItsOwn(t *testing.T) { } } -// Registration refuses a stream the mesh could not hold or would leave ambiguous (ADR 0363 §1, §2, §4). -func TestAStreamIsNamedBoundedAndGrantedByATrustedConfigValue(t *testing.T) { - grants := `"grant":{"config":"grants","events":"mail.{key}.**"}` - for _, c := range []struct{ manifest, says string }{ - {mailManifest(t, `[{"name":"Mail","events":["mail.**"]}]`, ""), `a stream is named locally`}, - {mailManifest(t, `[{"name":"mail.x","events":["mail.**"]}]`, ""), `a stream is named locally`}, - {mailManifest(t, `[{"name":"events","events":["mail.**"]}]`, ""), `the mesh's own stream EVENTS`}, - {mailManifest(t, `[{"name":"log-x","events":["mail.**"]}]`, ""), `starts with LOG_`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"mail","events":["x.**"]}]`, ""), `twice`}, - {mailManifest(t, `[{"name":"mail","events":[]}]`, ""), `with no events`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**.x"]}]`, ""), `nothing may follow it`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-age-days":31}]`, ""), `between 1 and 30`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-mib":9000}]`, ""), `between 1 and 8192`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"stream":"MAIL"}]`, ""), `{name, events`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"]}]`, `"emits":["mail.mesh.message.added"],`), - `which its stream "mail" carries`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"more","events":["mail.x.*"]}]`, ""), - `both carry`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"tags","events":"mail.{key}.**"}}]`, ""), - `is not a trusted config value`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"mail.**"}}]`, ""), - `exactly once`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"{key}.{key}"}}]`, ""), - `exactly once`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],`+grants+`,"why":1}]`, ""), `{name, events`}, - {`{"module":"a.b","version":"1","streams":[{"name":"x","events":["x"]}]}`, `no dot`}, - } { - _, err := ParseManifest([]byte(c.manifest)) - if err == nil { - t.Errorf("%s was accepted", c.manifest) - continue - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) - } - } -} - -// A stream name has one owner across the catalogue (ADR 0363 §1). -func TestTwoModulesCannotDeclareOneStream(t *testing.T) { - a := Manifest{Module: "a", Streams: []StreamDeclaration{{Name: "mail", Events: []string{"x"}}}} - b := Manifest{Module: "b", Streams: []StreamDeclaration{{Name: "mail", Events: []string{"y"}}}} - got := strings.Join(CatalogueProblems(Shelf{"a": a, "b": b}), "; ") - if !strings.Contains(got, "b declares the stream MAIL, which a already declares") { - t.Fatalf("a second owner of MAIL was not refused: %s", got) - } - if got := StreamNameProblems(Shelf{"a": a}); len(got) != 0 { - t.Fatalf("one owner refused: %v", got) - } - // And a stream of its own is outside the bus's predefined forms (the lead, 2026-10-29): refused, naming it, unless it - // is one kept by name until its module moves it. - if !strings.Contains(got, `a keeps the stream "mail", outside the bus's predefined forms`) { - t.Fatalf("a stream of its own was not refused: %s", got) - } - if got := PredefinedFormProblems(Manifest{Module: "mesh-mail", Streams: a.Streams}); len(got) != 0 { - t.Fatalf("a stream kept until it moves was refused: %v", got) - } -} - // The setting's value names who hears each key: a module wherever it runs, or on one machine (ADR 0363 §4). func TestARestrictingSettingNamesAModuleOrAModuleOnAMachine(t *testing.T) { var value any @@ -130,7 +70,7 @@ func TestARestrictingSettingNamesAModuleOrAModuleOnAMachine(t *testing.T) { // A consumer hears a restricted stream only where its consumes asks and the setting grants it, and only // by naming the emitter (ADR 0363 §5); what a stream carries whole leaves EVENTS. func TestAConsumerHearsOnlyWhatItAsksAndIsGranted(t *testing.T) { - provider, err := ParseManifest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`))) + provider, err := decodeForTest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`))) if err != nil { t.Fatal(err) } @@ -212,41 +152,3 @@ func TestNarrowingTwoPatterns(t *testing.T) { t.Fatalf("uncovered: %v", got) } } - -// **A module that hears a restricted stream runs as an account of its own** (ADR 0376 §2): registration refuses a -// `consumes` naming a module and asking for events its restricted stream carries, unless the manifest declares -// `runs-as`; a stream without a grant, an event the provider says on EVENTS, and a wildcard emitter stay as they -// were, and a consumer whose provider is not on the shelf is judged once it is. -func TestHearingARestrictedStreamNeedsAnAccountOfItsOwn(t *testing.T) { - provider, err := ParseManifest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`))) - if err != nil { - t.Fatal(err) - } - feed := Manifest{Module: "feed", Streams: []StreamDeclaration{{Name: "items", Events: []string{"item.**"}}}} - carried := Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.*.message.added"}} - got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "reader": carried}) - if len(got) != 1 || !strings.Contains(got[0], "reader consumes mesh-mail.mail.*.message.added") || - !strings.Contains(got[0], "MAIL") || !strings.Contains(got[0], "runs-as") || !strings.Contains(got[0], "ADR 0376") { - t.Fatalf("a carried module hearing MAIL was not refused as it should be: %v", got) - } - if all := strings.Join(CatalogueProblems(Shelf{"mesh-mail": provider, "reader": carried}), "; "); !strings.Contains(all, got[0]) { - t.Fatalf("the catalogue's check does not refuse it: %s", all) - } - own := carried - own.RunsAs = "reader" - if got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "reader": own}); len(got) != 0 { - t.Fatalf("a module of its own account was refused: %v", got) - } - for _, m := range []Manifest{ - {Module: "events", Consumes: []string{"mesh-mail.account.added"}}, - {Module: "items", Consumes: []string{"feed.item.**"}}, - {Module: "audit", Consumes: []string{"**", "*.mail.**"}}, - } { - if got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "feed": feed, m.Module: m}); len(got) != 0 { - t.Errorf("%s was refused: %v", m.Module, got) - } - } - if got := RestrictedHearingProblems(Shelf{"reader": carried}); len(got) != 0 { - t.Fatalf("judged without its provider: %v", got) - } -} diff --git a/go/catalogue/template.go b/go/catalogue/template.go index 455afd8..a1d06ac 100644 --- a/go/catalogue/template.go +++ b/go/catalogue/template.go @@ -8,7 +8,6 @@ import ( "os" "path" "path/filepath" - "sort" "strings" "unicode/utf8" ) @@ -50,26 +49,6 @@ func (m Manifest) TemplatesDir() string { return DefaultTemplatesDir } -// templatesDirProblem is what is wrong with the templates directory a manifest names, or "": one directory of the -// module's own, a plain relative path inside it — never absolute, never leaving it, never the module's directory -// itself — and named only by a module that names a template, since a word read by nothing is refused. -func templatesDirProblem(m Manifest, templated bool) string { - d := m.Templates - if d == "" { - return "" - } - if strings.HasPrefix(d, "/") || strings.Contains(d, "\\") || path.Clean(d) != d || d == "." || d == ".." || - strings.HasPrefix(d, "../") { - return fmt.Sprintf("%s names the templates directory %q, which is not a directory of its own: a plain "+ - "relative path inside the module's directory, such as \"templates\" (issue 731)", m.Module, d) - } - if !templated { - return fmt.Sprintf("%s names the templates directory %q and names no template: a file resource names its "+ - "template as %q, or the manifest says no templates directory", m.Module, d, d+"/") - } - return "" -} - // Templated is whether any of the manifest's resources, or any item it contributes (collections.go), names a // template. func (m Manifest) Templated() bool { @@ -146,39 +125,6 @@ func templateOf(module, dir string, r map[string]any) (string, bool, string) { return name, true, "" } -// templateProblems is what is wrong with the manifest's templates, as written: a template on something not a file, -// beside content, or outside the module's templates directory; a templates directory that is not one, or that no -// template is in; or a module naming templates whose build packs no archive from that directory. Whether each -// template exists is judged where a tree is (ReadTemplates) or the archive's text is (Resolve). -func templateProblems(m Manifest) []string { - var problems []string - templated := m.Templated() - if wrong := templatesDirProblem(m, templated); wrong != "" { - return append(problems, wrong) - } - dir := m.TemplatesDir() - for _, r := range m.Resources { - if _, _, wrong := templateOf(m.Module, dir, r); wrong != "" { - problems = append(problems, wrong) - } - } - if !templated { - return problems - } - switch found := templatesArtifacts(m); len(found) { - case 0: - problems = append(problems, fmt.Sprintf("%s names templates and its build packs no archive from %s/: "+ - "declare an artifact {\"name\": %q, \"kind\": \"archive\", \"from\": %q} (novox/hq ADR 0349 §2)", - m.Module, dir, path.Base(dir), dir)) - case 1: - default: - sort.Strings(found) - problems = append(problems, fmt.Sprintf("%s packs %s/ into %s; its templates are read from one archive", - m.Module, dir, strings.Join(found, " and "))) - } - return problems -} - // fillTemplates is resources with each template's text as its content and the template key dropped: text says the text // of a template by its path ("/"), and whether it has one. A template it has no text for, or // text that is not UTF-8, is refused by name. Every resource is copied; the manifest's own are left as they were. diff --git a/go/catalogue/template_test.go b/go/catalogue/template_test.go index a439acb..93698e1 100644 --- a/go/catalogue/template_test.go +++ b/go/catalogue/template_test.go @@ -49,7 +49,7 @@ func builtFiles(texts map[string]string) []Built { // held is a manifest as registration holds it: resolved with what was built, then read again strictly. func held(t *testing.T, raw string, built []Built) Manifest { t.Helper() - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err != nil { t.Fatal(err) } @@ -61,7 +61,7 @@ func held(t *testing.T, raw string, built []Built) Manifest { if err != nil { t.Fatal(err) } - out, err := ParseManifest(again) + out, err := decodeForTest(again) if err != nil { t.Fatal(err) } @@ -107,7 +107,7 @@ func TestATemplatedModuleReadFromItsTreeComposesByteIdenticallyToItsInlineTwin(t t.Fatal(err) } } - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -127,52 +127,8 @@ func TestATemplatedModuleReadFromItsTreeComposesByteIdenticallyToItsInlineTwin(t } } -func TestAFileNamingBothATemplateAndContentIsRefused(t *testing.T) { - raw := strings.Replace(templatedTwin, `"template":"files/tidy.conf"`, - `"template":"files/tidy.conf","content":"x"`, 1) - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "never both") { - t.Fatalf("a file with both a template and content was not refused by name: %v", err) - } -} - -func TestATemplateOutsideFilesIsRefusedByName(t *testing.T) { - for _, bad := range []string{"../tidy.conf", "/etc/tidy.conf", "files", "files/", "files/../tidy.conf", - "files/./tidy.conf", "other/tidy.conf", "tidy.conf", "files//tidy.conf", `files\\tidy.conf`} { - raw := strings.Replace(templatedTwin, `"files/tidy.conf"`, `"`+bad+`"`, 1) - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "not a file under files/") { - t.Errorf("the template %q was not refused by name: %v", bad, err) - } - } -} - -func TestATemplateOnAResourceThatIsNotAFileIsRefused(t *testing.T) { - raw := strings.Replace(templatedTwin, `{"id":"conf","type":"directory","path":"${dir:conf}"}`, - `{"id":"conf","type":"directory","path":"${dir:conf}","template":"files/tidy.conf"}`, 1) - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "conf is a directory and names a template") { - t.Fatalf("a directory naming a template was not refused: %v", err) - } -} - -func TestATemplatedModuleWhoseBuildPacksNoFilesIsRefused(t *testing.T) { - for _, build := range []string{ - ``, - `"build":{"artifacts":[{"name":"look","kind":"archive","from":"theme"}]},`, - `"build":{"artifacts":[{"name":"a","kind":"archive","from":"files"},{"name":"b","kind":"archive","from":"./files/"}]},`, - } { - raw := strings.Replace(templatedTwin, - `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, build, 1) - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "files/") { - t.Errorf("a templated module with the build %q was not refused: %v", build, err) - } - } -} - func TestAMissingTemplateIsRefusedByName(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -198,7 +154,7 @@ func TestAMissingTemplateIsRefusedByName(t *testing.T) { } func TestATemplateThatIsASymlinkIsRefused(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -221,7 +177,7 @@ func TestATemplateThatIsASymlinkIsRefused(t *testing.T) { } func TestAResolveWithoutTheArchivesTextIsRefused(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -231,7 +187,7 @@ func TestAResolveWithoutTheArchivesTextIsRefused(t *testing.T) { } func TestATemplateThatIsNotUTF8IsRefused(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -245,7 +201,7 @@ func TestATemplateThatIsNotUTF8IsRefused(t *testing.T) { // What registration refuses: a manifest to be held that still names a template — one resolved by a builder that // predates templates would otherwise compose files with no text. func TestAManifestStillNamingATemplateIsNotHeld(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -260,7 +216,7 @@ func TestAManifestStillNamingATemplateIsNotHeld(t *testing.T) { // A symlinked files/, or a symlinked directory under it, would read a file outside the tree: refused by name, // as is any symlink or special file under files/ that no template names, which the build's packing refuses. func TestASymlinkAnywhereUnderFilesIsRefusedByName(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/templates_dir_test.go b/go/catalogue/templates_dir_test.go index ad740b0..ef79862 100644 --- a/go/catalogue/templates_dir_test.go +++ b/go/catalogue/templates_dir_test.go @@ -85,7 +85,7 @@ func TestAModuleWithItsOwnTemplatesDirectoryIsReadFromItsTree(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "files", "bin", "tidy"), []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } - m, err := ParseManifest([]byte(unpackingTemplated)) + m, err := decodeForTest([]byte(unpackingTemplated)) if err != nil { t.Fatal(err) } @@ -110,38 +110,6 @@ func TestAModuleWithItsOwnTemplatesDirectoryIsReadFromItsTree(t *testing.T) { } } -func TestATemplateOutsideTheNamedTemplatesDirectoryIsRefused(t *testing.T) { - raw := strings.Replace(unpackingTemplated, `"template":"templates/tidy.conf"`, `"template":"files/tidy.conf"`, 1) - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "files/tidy.conf") || - !strings.Contains(err.Error(), "templates/") { - t.Fatalf("a template outside the named templates directory was not refused by name: %v", err) - } -} - -func TestATemplatesDirectoryNoArchiveIsBuiltFromIsRefused(t *testing.T) { - raw := strings.Replace(unpackingTemplated, `, - {"name":"templates","kind":"archive","from":"templates"}`, "", 1) - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "no archive from templates/") { - t.Fatalf("a module whose build packs no archive from its templates directory was not refused: %v", err) - } -} - -func TestATemplatesDirectoryOnAModuleNamingNoTemplateIsRefused(t *testing.T) { - raw := strings.Replace(unpackingInline, `"version":"1",`, `"version":"1","templates":"templates",`, 1) - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "names no template") { - t.Fatalf("a templates directory read by nothing was not refused: %v", err) - } -} - -func TestATemplatesDirectoryThatIsNotAPlainRelativeDirectoryIsRefused(t *testing.T) { - for _, dir := range []string{"/etc", "../other", "..", "templates/../files", "./templates", ".", "a\\b", "templates/"} { - raw := strings.Replace(unpackingTemplated, `"templates":"templates"`, `"templates":`+jsonString(dir), 1) - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "templates directory") { - t.Errorf("the templates directory %q was not refused: %v", dir, err) - } - } -} - func TestATemplatesDirectoryNamedFilesIsTheDefault(t *testing.T) { raw := strings.Replace(templatedTwin, `"version":"1",`, `"version":"1","templates":"files",`, 1) if got, want := composed(t, held(t, raw, builtFiles(templateTexts(t)))), diff --git a/go/catalogue/terminal_keys.go b/go/catalogue/terminal_keys.go index b80e89e..2d8811b 100644 --- a/go/catalogue/terminal_keys.go +++ b/go/catalogue/terminal_keys.go @@ -164,25 +164,3 @@ func asksFor(r map[string]any) []string { sort.Strings(asked) return asked } - -// TrustProblems are the ways a manifest states `trusted` wrongly: anything but true or false, or on anything but -// a file. Refused at registration and by `module check`. -func TrustProblems(m Manifest) []string { - var out []string - for _, r := range m.Resources { - v, said := r[TrustedField] - if !said { - continue - } - if fmt.Sprint(r["type"]) != "file" { - out = append(out, fmt.Sprintf("%s: %v is a %v and says %q; only a file says whether the settings it "+ - "asks for are trusted (novox/hq issue 339)", m.Module, r["id"], r["type"], TrustedField)) - continue - } - if _, ok := v.(bool); !ok { - out = append(out, fmt.Sprintf("%s: %v says %q as %v; it is true or false (novox/hq issue 339)", - m.Module, r["id"], TrustedField, v)) - } - } - return out -} diff --git a/go/catalogue/terminal_settings_test.go b/go/catalogue/terminal_settings_test.go index d822a89..a7ce7a6 100644 --- a/go/catalogue/terminal_settings_test.go +++ b/go/catalogue/terminal_settings_test.go @@ -192,30 +192,3 @@ func TestOnlyANamedKeyOfATrustedFileIsTheTerminals(t *testing.T) { t.Fatalf("a file that names no keys still takes any key, so its module's layer stays the terminal's: %v", got) } } - -// A file that asks for a setting says whether what it asks is trusted (novox/hq issue 339): `trusted` is a -// boolean, on a file alone, and a file asking for a setting without it is named. -func TestAFileSaysWhetherItsSettingsAreTrusted(t *testing.T) { - m := Manifest{Module: "power", Resources: []map[string]any{ - {"id": "said", "type": "file", "path": "/etc/a", "trusted": true, "content": "${setting:a}"}, - {"id": "unsaid", "type": "file", "path": "/etc/b", "content": "${setting:b}"}, - {"id": "no-setting", "type": "file", "path": "/etc/c", "content": "plain"}}} - if got := strings.Join(UnsaidTrust(m), ","); got != "unsaid" { - t.Errorf("unsaid: %s; want unsaid", got) - } - // A mergeable file that names keys asks for them, so it is named too; one that names none asks for nothing. - merged := Manifest{Module: "agent", Resources: []map[string]any{ - {"id": "settings", "type": "file", "path": "/a", "merge": MergeJSON, "content": `{"managed_settings": {}}`}, - {"id": "blank", "type": "file", "path": "/b", "merge": MergeJSON, "content": `{}`}}} - if got := strings.Join(UnsaidTrust(merged), ","); got != "settings" { - t.Errorf("unsaid: %s; want settings", got) - } - for _, bad := range []map[string]any{ - {"id": "x", "type": "file", "path": "/etc/x", "trusted": "yes", "content": "${setting:a}"}, - {"id": "y", "type": "directory", "trusted": true}, - } { - if problems := TrustProblems(Manifest{Module: "power", Resources: []map[string]any{bad}}); len(problems) == 0 { - t.Errorf("%v was taken", bad) - } - } -} diff --git a/go/catalogue/unconsumed_placeholder_test.go b/go/catalogue/unconsumed_placeholder_test.go index c82df10..bd9b09f 100644 --- a/go/catalogue/unconsumed_placeholder_test.go +++ b/go/catalogue/unconsumed_placeholder_test.go @@ -19,53 +19,6 @@ const ( shellsOperators = "${PORT:-8080} ${SHELL:-/bin/sh} ${SECRET:?unset} ${dir:-/tmp}" ) -// The catalogue check — the strict parse registration runs too — refuses each by name, with the -// module and the field it stands in, and says nothing about the shell's own syntax. -func TestAMisspelledPlaceholderIsRefusedAtTheCheck(t *testing.T) { - raw := `{"module":"speller","version":"1","resources":[{"id":"rc","type":"file","path":"/etc/speller.rc",` + - `"content":"a=` + misspelledShell + `\nb=` + undeclaredSetting + `\nc=` + misspelledMachine + - `\nd=` + shellsOwn + `\n"}]}` - _, err := ParseManifest([]byte(raw)) - if err == nil { - t.Fatal("a file holding three placeholders no pass consumes was accepted") - } - for _, token := range []string{misspelledShell, undeclaredSetting, misspelledMachine} { - if !strings.Contains(err.Error(), "speller's resource rc holds "+token+" in its content") { - t.Errorf("the refusal does not name %s with its module and field: %v", token, err) - } - } - if strings.Contains(err.Error(), "XDG_CACHE_HOME") { - t.Errorf("the shell's own syntax was refused: %v", err) - } - - // A namespace the mesh knows, misspelt in any way its own pattern does not take, and the same - // namespace in a field its pass does not read: refused at the check as at composition. - for _, c := range []struct{ resource, token, field string }{ - {`{"id":"rc","type":"file","path":"/etc/rc","content":"${Machine:address}"}`, "${Machine:address}", "content"}, - {`{"id":"rc","type":"file","path":"/etc/rc","content":"${machine:.address}"}`, "${machine:.address}", "content"}, - {`{"id":"rc","type":"file","path":"/etc/rc","content":"${machine: address}"}`, "${machine: address}", "content"}, - {`{"id":"rc","type":"file","path":"/etc/rc","content":"${Dir:x}"}`, "${Dir:x}", "content"}, - {`{"id":"rc","type":"file","path":"/etc/rc","content":"a=${machine:address\nb=1"}`, "${machine:address", "content"}, - {`{"id":"rc","type":"process","name":"speller","env":{"NAME":"${machine:name}"}}`, "${machine:name}", "env.NAME"}, - } { - raw := `{"module":"speller","version":"1","resources":[` + c.resource + `]}` - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "speller's resource rc holds "+c.token+" in its "+c.field) { - t.Errorf("%s in its %s was accepted at the check, or not named: %v", c.token, c.field, err) - } - } - - // And the shell's syntax alone, beside placeholders every pass knows, is accepted — as is the - // shape a lower-case shell variable takes with an operator after its colon. - raw = `{"module":"speller","version":"1","resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.rc",` + - `"content":"` + shellsOwn + ` ${(%):-%n} ${1:-.} ${count:-} ${trial:+ on trial} ${machine:address} ` + - shellsOperators + `\n"},` + - `{"id":"server","type":"container","name":"server","env":{"PORT":"${PORT:-80}"}}]}` - if _, err := ParseManifest([]byte(raw)); err != nil { - t.Fatalf("the shell's own syntax was refused: %v", err) - } -} - // Composition refuses the same placeholders in the same words — a manifest the store already holds // was never parsed by this binary — and a namespace the mesh knows, written in a field its pass does // not read, is refused there too, because it would reach the machine as the same literal text. diff --git a/go/catalogue/unhealthy.go b/go/catalogue/unhealthy.go index b6e5583..f4fd74a 100644 --- a/go/catalogue/unhealthy.go +++ b/go/catalogue/unhealthy.go @@ -1,10 +1,5 @@ package catalogue -import ( - "fmt" - "strings" -) - // How loud a module's unhealthy condition is (novox/hq ADR 0355, extending ADR 0240). // // **A module may declare its unhealthy urgent.** A module whose failure costs people something they rely on @@ -29,26 +24,6 @@ type UnhealthyPolicy struct { Why string `json:"why,omitempty"` } -// unhealthyProblems refuses an `unhealthy` without a why, and any severity but urgent or warning (novox/hq ADR -// 0355 §2). -func (m Manifest) unhealthyProblems() []string { - if m.Unhealthy == nil { - return nil - } - var problems []string - switch m.Unhealthy.Severity { - case UnhealthyUrgent, UnhealthyWarning: - default: - problems = append(problems, fmt.Sprintf("unhealthy is %q or %q, not %q", UnhealthyUrgent, UnhealthyWarning, - m.Unhealthy.Severity)) - } - if strings.TrimSpace(m.Unhealthy.Why) == "" { - problems = append(problems, "unhealthy says why: a module that declares how loud its failure is names "+ - "what people lose while it is broken") - } - return problems -} - // UnhealthyIsUrgent is whether the module declares its unhealthy condition urgent (novox/hq ADR 0355 §1). func (m Manifest) UnhealthyIsUrgent() bool { return m.Unhealthy != nil && m.Unhealthy.Severity == UnhealthyUrgent diff --git a/go/catalogue/unhealthy_test.go b/go/catalogue/unhealthy_test.go index c6c9b88..c0853fa 100644 --- a/go/catalogue/unhealthy_test.go +++ b/go/catalogue/unhealthy_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -9,7 +8,7 @@ import ( // severity but urgent or warning (novox/hq ADR 0355). func TestAnUnhealthyDeclaredUrgentIsUrgent(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mail","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"mail","version":"1",` + `"unhealthy":{"severity":"urgent","why":"mail is refused or stuck for everyone with a mailbox"}}`)) if err != nil { t.Fatalf("a manifest declaring its unhealthy urgent with why was refused: %v", err) @@ -18,36 +17,3 @@ func TestAnUnhealthyDeclaredUrgentIsUrgent(t *testing.T) { t.Errorf("a manifest declaring its unhealthy urgent reads as not urgent: %+v", m.Unhealthy) } } - -func TestAManifestWithoutUnhealthyIsNotUrgent(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"notes","version":"1"}`)) - if err != nil { - t.Fatal(err) - } - if m.UnhealthyIsUrgent() { - t.Errorf("a manifest saying nothing of its unhealthy reads as urgent") - } - warning := Manifest{Module: "notes", Unhealthy: &UnhealthyPolicy{Severity: UnhealthyWarning, Why: "notes wait"}} - if warning.UnhealthyIsUrgent() || len(warning.unhealthyProblems()) != 0 { - t.Errorf("a warning said with why reads as urgent or is refused: %v", warning.unhealthyProblems()) - } -} - -func TestAnUnhealthyWithoutWhyIsRefused(t *testing.T) { - for _, why := range []string{"", " "} { - if problems := (Manifest{Module: "mail", Unhealthy: &UnhealthyPolicy{Severity: UnhealthyUrgent, Why: why}}).unhealthyProblems(); len(problems) == 0 { - t.Errorf("urgent with why %q was accepted", why) - } - } - if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"urgent"}}`)); err == nil || - !strings.Contains(err.Error(), "unhealthy says why") { - t.Errorf("a manifest declaring its unhealthy urgent without why parsed: %v", err) - } -} - -func TestAnUnhealthySeverityOtherThanUrgentOrWarningIsRefused(t *testing.T) { - if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"loud","why":"mail stops"}}`)); err == nil || - !strings.Contains(err.Error(), `not "loud"`) { - t.Errorf("a manifest declaring its unhealthy loud parsed: %v", err) - } -} diff --git a/go/catalogue/upgrade.go b/go/catalogue/upgrade.go index d498f56..037e6cc 100644 --- a/go/catalogue/upgrade.go +++ b/go/catalogue/upgrade.go @@ -35,24 +35,6 @@ type UpgradePolicy struct { Why string `json:"why,omitempty"` } -func (m Manifest) upgradeProblems() []string { - if m.Upgrade == nil { - return nil - } - switch m.Upgrade.Policy { - case PolicyRoll: - case PolicyTogether, PolicyRecord: - if strings.TrimSpace(m.Upgrade.Why) == "" { - return []string{fmt.Sprintf("upgrade %q says why: a module that does not roll out one machine first "+ - "names the reason a person reads", m.Upgrade.Policy)} - } - default: - return []string{fmt.Sprintf("upgrade is %q, %q or %q, not %q", PolicyRoll, PolicyTogether, PolicyRecord, - m.Upgrade.Policy)} - } - return nil -} - // Where a policy came from, as `upgrade` says it. const ( FromPerson = "person" diff --git a/go/catalogue/upgrade_test.go b/go/catalogue/upgrade_test.go index e007897..1b52013 100644 --- a/go/catalogue/upgrade_test.go +++ b/go/catalogue/upgrade_test.go @@ -1,69 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A module rolls out by default; it records where it says so with why, where it keeps irreplaceable -// data, and always where it is the bus (novox/hq ADR 0236). -func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) { - cases := []struct { - name string - m Manifest - policy, from string - }{ - {"default", Manifest{Module: "app"}, PolicyRoll, FromDefault}, - {"says record", Manifest{Module: "db", Upgrade: &UpgradePolicy{Policy: PolicyRecord, Why: "a restart costs"}}, - PolicyRecord, FromModule}, - {"says together", Manifest{Module: "dns", Upgrade: &UpgradePolicy{Policy: PolicyTogether, Why: "one zone"}}, - PolicyTogether, FromModule}, - {"irreplaceable data", Manifest{Module: "media", Data: &Data{Own: []DataItem{{ID: "library", Class: ClassIrreplaceable}}}}, - PolicyRecord, FromData}, - {"valuable data rolls", Manifest{Module: "notes", Data: &Data{Own: []DataItem{{ID: "db", Class: ClassValuable}}}}, - PolicyRoll, FromDefault}, - {"irreplaceable but says roll", Manifest{Module: "photos", Upgrade: &UpgradePolicy{Policy: PolicyRoll}, - Data: &Data{Own: []DataItem{{ID: "originals", Class: ClassIrreplaceable}}}}, PolicyRoll, FromModule}, - {"the bus, whatever it says", Manifest{Module: "nats", Provides: []Offer{{Name: "mesh-bus"}}, - Upgrade: &UpgradePolicy{Policy: PolicyRoll}}, PolicyRecord, FromBus}, - } - for _, c := range cases { - policy, from, _ := DerivedUpgrade(c.m) - if policy != c.policy || from != c.from { - t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from) - } - // ADR 0565 clause 7 (issue 1953 L5-S3): a person's choice wins over each source but the bus; none leaves the - // derivation as it was. - person := &PersonPolicy{V: PersonPolicyVersion, Module: c.m.Module, Policy: PolicyTogether, Why: "one zone", By: "person.jochen"} - policy, from, _, by := Upgrade(c.m, person) - wantPolicy, wantFrom, wantBy := PolicyTogether, FromPerson, "person.jochen" - if c.from == FromBus { - wantPolicy, wantFrom, wantBy = c.policy, FromBus, "" - } - if policy != wantPolicy || from != wantFrom || by != wantBy { - t.Errorf("%s with a person's choice: %s from %s by %q", c.name, policy, from, by) - } - if policy, from, _, _ := Upgrade(c.m, nil); policy != c.policy || from != c.from { - t.Errorf("%s with no choice: %s from %s", c.name, policy, from) - } - } - for _, p := range []PersonPolicy{{Module: "m", Policy: "sometimes", Why: "x", By: "p"}, {Module: "m", Policy: PolicyRecord, By: "p"}, - {Module: "m", Policy: PolicyRecord, Why: "x"}} { - if p.Check() == nil { - t.Errorf("%+v was accepted", p) - } - } - // A policy other than roll says why, and an unknown one is refused. - for _, u := range []UpgradePolicy{{Policy: PolicyRecord}, {Policy: PolicyTogether}, {Policy: "sometimes", Why: "x"}} { - if problems := (Manifest{Module: "m", Upgrade: &u}).upgradeProblems(); len(problems) == 0 { - t.Errorf("%+v was accepted", u) - } - } - if problems := (Manifest{Module: "m", Upgrade: &UpgradePolicy{Policy: PolicyRecord, Why: "a restart costs"}}).upgradeProblems(); len(problems) != 0 { - t.Errorf("a record with why was refused: %v", problems) - } - if _, err := ParseManifest([]byte(`{"module":"m","upgrade":{"policy":"record"}}`)); err == nil || - !strings.Contains(err.Error(), "says why") { - t.Errorf("a manifest recording without why parsed: %v", err) - } -} diff --git a/go/catalogue/upstream_file_test.go b/go/catalogue/upstream_file_test.go index ca3016d..204d492 100644 --- a/go/catalogue/upstream_file_test.go +++ b/go/catalogue/upstream_file_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -21,81 +20,8 @@ func TestAnArchivePinnedByURLAndSha256IsAccepted(t *testing.T) { `{"name":"addon","kind":"archive","url":"https://vendor.example.org/addon.xpi","sha256":"` + pinnedSum + `","version":"1.5.6.1","file":"addon.xpi"}`, } { - if _, err := ParseManifest([]byte(upstreamManifest(a))); err != nil { + if _, err := decodeForTest([]byte(upstreamManifest(a))); err != nil { t.Errorf("%s was refused: %v", a, err) } } } - -func TestAnUpstreamFileThatIsNotPinnedIsRefused(t *testing.T) { - cases := map[string]string{ - // No digest: what the address serves changes under the mesh. - `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","version":"1"}`: "without a pinned sha256", - `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"ABC","version":"1"}`: "without a pinned sha256", - // No version: a person reading the pin cannot tell which release. - `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `"}`: "says no version", - // Not https. - `{"name":"x","kind":"archive","url":"http://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `","version":"1"}`: "over https", - // Both a directory of the repository and a URL. - `{"name":"x","kind":"archive","from":"files","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + - `","version":"1"}`: "one or the other", - // A pin without a URL. - `{"name":"x","kind":"archive","from":"files","sha256":"` + pinnedSum + `"}`: "no url", - // Only an archive is mirrored from a file. - `{"name":"x","kind":"image","from":"Dockerfile","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + - `","version":"1"}`: "only an archive", - // A file name that is a path, a directory within that leaves the archive, and both at once. - `{"name":"x","kind":"archive","url":"https://vendor.invalid/a.xpi","sha256":"` + pinnedSum + - `","version":"1","file":"../a.xpi"}`: "not a plain relative file name", - `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + - `","version":"1","within":"../up"}`: "not a plain relative directory", - `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + - `","version":"1","within":"r","file":"r.tar.xz"}`: "a file is kept whole", - } - for artifact, want := range cases { - _, err := ParseManifest([]byte(upstreamManifest(artifact))) - if err == nil { - t.Errorf("%s was accepted", artifact) - continue - } - if !strings.Contains(err.Error(), want) { - t.Errorf("%s was refused for another reason than %q: %v", artifact, want, err) - } - } -} - -func TestAnUpstreamFilesVendorIsNamedOnPurposeAndAnInstallationsIsRefused(t *testing.T) { - // The vendor's host is judged like any name, and passes through names-on-purpose; a version of four dotted - // numbers is not an address, and a name in it is still judged. - m, err := ParseManifest([]byte(upstreamManifest(`{"name":"owl","kind":"archive","url":"https://vendor.be/owl/owl.xpi","sha256":"` + - pinnedSum + `","version":"1.5.6.1","file":"owl.xpi","names-on-purpose":{"vendor.be":"Owl's vendor publishes it there"}}`))) - if err != nil { - t.Fatal(err) - } - if problems := InstallationProblems(m); len(problems) != 0 { - t.Fatalf("the declared vendor and the version were taken for an installation: %v", problems) - } - m.Build.Artifacts[0].NamesOnPurpose = nil - if problems := InstallationProblems(m); len(problems) != 1 || !strings.Contains(problems[0], "vendor.be") { - t.Fatalf("an undeclared vendor host is judged: %v", problems) - } - m.Build.Artifacts[0].Version = "git.novox.be 10.0.0.1" - if problems := InstallationProblems(m); len(problems) != 2 { - t.Fatalf("a name in the version is judged: %v", problems) - } - m.Build.Artifacts[0].URL, m.Build.Artifacts[0].Version = "https://mail.novox.be/owl.xpi", "1" - if problems := InstallationProblems(m); len(problems) != 1 || !strings.Contains(problems[0], "mail.novox.be") { - t.Fatalf("an installation's host in the url is judged: %v", problems) - } -} - -func TestAnUpstreamFileFromAnAddressOrAReservedNameIsRefused(t *testing.T) { - for _, u := range []string{"https://git.novox.internal/r.tar.xz", "https://10.1.2.3/r.tar.xz", - "https://203.0.113.9/r.tar.xz", "https://[2001:db8::1]/r.tar.xz", "https://nas.lan/r.tar.xz", "https://vendor/r.tar.xz"} { - _, err := ParseManifest([]byte(upstreamManifest(`{"name":"x","kind":"archive","url":"` + u + `","sha256":"` + - pinnedSum + `","version":"1"}`))) - if err == nil || !strings.Contains(err.Error(), "published under a public name") { - t.Errorf("%s: %v", u, err) - } - } -} diff --git a/go/catalogue/user_namespaces_test.go b/go/catalogue/user_namespaces_test.go index fa595e3..1b52013 100644 --- a/go/catalogue/user_namespaces_test.go +++ b/go/catalogue/user_namespaces_test.go @@ -1,35 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// 1354/AC3: a contribution placed only where the machine reports the user namespaces capability (the name issue 1308 has -// the node-engine report) is accepted, and a misspelt name is still refused with today's words. The manifests are the -// shape of TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly (mesh-control 6c2a8d62 -// internal/catalogue/seat_data_test.go:105), which stays in mesh-controller with the tests that read its other packages. -func TestIfACapabilityMayNameUserNamespaces(t *testing.T) { - const piece = `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":%q,"data":{"bar":"top","place":"status","shows":"battery"}}]}` - if _, err := ParseManifest([]byte(strings.Replace(piece, "%q", `"user-namespaces"`, 1))); err != nil { - t.Fatalf("if-capability user-namespaces was refused: %v", err) - } - for name, want := range map[string]string{ - "user-namespace": "which no machine reports", - "userns": "which no machine reports", - "user_namespaces": "is not a capability's name", - } { - _, err := ParseManifest([]byte(strings.Replace(piece, "%q", `"`+name+`"`, 1))) - if err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: accepted, or refused without today's words %q: %v", name, want, err) - } - } - if !oneOf(KnownCapabilities, "user-namespaces") { - t.Error("user-namespaces is not a known capability") - } - for _, name := range []string{"battery", "power-meter", "seat", "container-runtime"} { - if !oneOf(KnownCapabilities, name) { - t.Errorf("%s is no longer a known capability", name) - } - } -} diff --git a/go/catalogue/verbs_test.go b/go/catalogue/verbs_test.go index cda55ca..bf90983 100644 --- a/go/catalogue/verbs_test.go +++ b/go/catalogue/verbs_test.go @@ -7,7 +7,7 @@ import ( // A served verb is written as a bare name or in full, and both read into one type (novox/hq ADR 0132). func TestAServedVerbIsANameOrADefinition(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["price","refund"],` + + m, err := decodeForTest([]byte(`{"module":"till","version":"1","tools":["price","refund"],` + `"seats":[{"name":"shop-till","serves":["price",{"name":"refund","description":"give it back",` + `"input":{"type":"object","properties":{"order":{"type":"string"}}}}]}],` + `"claims":[{"name":"shop-till","scope":"mesh"}]}`)) @@ -23,15 +23,6 @@ func TestAServedVerbIsANameOrADefinition(t *testing.T) { } } -// A misspelt key inside a verb's definition is refused, like one anywhere else in the manifest. -func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"till","version":"1",` + - `"seats":[{"name":"shop-till","serves":[{"name":"price","descripton":"typo"}]}]}`)) - if err == nil || !strings.Contains(err.Error(), "descripton") { - t.Fatalf("a verb with a misspelt key was accepted: %v", err) - } -} - // Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs. func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) { was := Seats() diff --git a/go/catalogue/waits_test.go b/go/catalogue/waits_test.go index adf2080..8cf7f8d 100644 --- a/go/catalogue/waits_test.go +++ b/go/catalogue/waits_test.go @@ -1,13 +1,12 @@ package catalogue import ( - "strings" "testing" ) // novox/hq ADR 0367 §2: the tool runner's module names, one by one, the event families a session may wait on. func TestTheToolRunnerMayNameTheEventsASessionWaitsOn(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"node-tools","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"node-tools","version":"1",` + `"waits":["mesh-knowledge.reviewed","operator-channel.decided.claude-code"]}`)) if err != nil { t.Fatal(err) @@ -16,28 +15,3 @@ func TestTheToolRunnerMayNameTheEventsASessionWaitsOn(t *testing.T) { t.Fatalf("waits not read: %v", m.Waits) } } - -// A family is named one by one, never by a wildcard, and only by the tool runner's module (ADR 0367 §2, §6): -// each refusal is said in the manifest's words, at parse, rather than at the composition of the bus's users. -func TestAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) { - for manifest, want := range map[string]string{ - `{"module":"shop","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only node-tools", - `{"module":"node-tools","version":"1","waits":["mesh-knowledge.*"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["**"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["mesh-knowledge.>"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["*.reviewed"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","waits":["a.b","a.b"]}`: "twice", - } { - _, err := ParseManifest([]byte(manifest)) - if err == nil { - t.Errorf("accepted: %s", manifest) - continue - } - if !strings.Contains(err.Error(), want) { - t.Errorf("%s refused for the wrong reason (want %q): %v", manifest, want, err) - } - } -} diff --git a/go/catalogue/while_stopped_test.go b/go/catalogue/while_stopped_test.go index bec7a05..8c47837 100644 --- a/go/catalogue/while_stopped_test.go +++ b/go/catalogue/while_stopped_test.go @@ -31,64 +31,11 @@ func TestAMaintenanceWindowOnItsOwnModulesContainerIsAccepted(t *testing.T) { "image": "registry@sha256:" + strings.Repeat("a", 64), "schedule": "30 3 * * *", "while-stopped": []any{"store"}, }) - if _, err := ParseManifest(raw); err != nil { + if _, err := decodeForTest(raw); err != nil { t.Fatalf("a step holding its own module's container still was refused: %v", err) } } -func TestAMaintenanceWindowIsRefusedWhereTheDefinitionShowsItCannotMean(t *testing.T) { - for _, c := range []struct { - name string - step map[string]any - says string - }{ - { - "on a step with no schedule", - map[string]any{"id": "collect", "type": "container", "name": "c", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "while-stopped": []any{"store"}}, - "gates what is declared after it", - }, - { - "on a run-once step, which already has order", - map[string]any{"id": "collect", "type": "container", "name": "c", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "run-once": true, "while-stopped": []any{"store"}}, - "A maintenance window is for a recurring step", - }, - { - "naming a container this module does not declare", - map[string]any{"id": "collect", "type": "container", "name": "c", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "schedule": "30 3 * * *", "while-stopped": []any{"the-broker"}}, - "could quiesce a neighbour could stop the mesh", - }, - { - "naming itself", - map[string]any{"id": "collect", "type": "container", "name": "c", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "schedule": "30 3 * * *", "while-stopped": []any{"collect"}}, - "naming itself", - }, - { - "written as something that is not a list", - map[string]any{"id": "collect", "type": "container", "name": "c", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "schedule": "30 3 * * *", "while-stopped": "store"}, - "a list of this module's container ids", - }, - } { - _, err := ParseManifest(aStoreManifest(c.step)) - if err == nil { - t.Errorf("%s was accepted", c.name) - continue - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: the refusal does not say %q:\n%v", c.name, c.says, err) - } - } -} - // A composed declaration names the step's held containers the way the machine knows them. // // **The gap that let a bug through to the control node.** The manifest says `while-stopped: diff --git a/go/catalogue/work_queue_consumer_test.go b/go/catalogue/work_queue_consumer_test.go index 4f98b6f..4ba4f9c 100644 --- a/go/catalogue/work_queue_consumer_test.go +++ b/go/catalogue/work_queue_consumer_test.go @@ -2,7 +2,6 @@ package catalogue import ( "strings" - "testing" ) // A work-queue stream is read by exactly one process of its own module (novox/hq ADR 0436 §6): named once @@ -34,42 +33,3 @@ func queueList(q string) []any { } return out } - -// 1178/AC2 (named): a work-queue stream no process names is refused. -func TestAWorkQueueStreamNoProcessNamesIsRefused(t *testing.T) { - m := mailSenderManifestWithQueue("") - got := strings.Join(WorkQueueStreamProblems(m), "; ") - if !strings.Contains(got, "mail-sends") || !strings.Contains(got, "no process names it") { - t.Fatalf("an unnamed work-queue stream: %q", got) - } -} - -// 1178/AC2 (named twice): a work-queue stream two processes name is refused. -func TestAWorkQueueStreamTwoProcessesNameIsRefused(t *testing.T) { - m := mailSenderManifestWithQueue("mail-sends") - m.Resources = append(m.Resources, map[string]any{"id": "resend", "type": "process", - "run": []any{"./mesh-mail", "send"}, "user": "mesh-mail-send", - "bus": map[string]any{"emits": []any{"mail.send.sent"}, "queues": []any{"mail-sends"}}}) - got := strings.Join(WorkQueueStreamProblems(m), "; ") - if !strings.Contains(got, "mail-sends") || !strings.Contains(got, "2 processes name") { - t.Fatalf("a work-queue stream two processes name: %q", got) - } -} - -// 1178/AC2 (named once): a work-queue stream exactly one process names is accepted. -func TestAWorkQueueStreamOneProcessNamesIsAccepted(t *testing.T) { - m := mailSenderManifestWithQueue("mail-sends") - if got := WorkQueueStreamProblems(m); len(got) != 0 { - t.Fatalf("a work-queue stream one process names was refused: %v", got) - } -} - -// 1178/AC2 (another module's consumes): a work-queue stream another module's consumes asks for is refused. -func TestAWorkQueueStreamAnotherModulesConsumesIsRefused(t *testing.T) { - sender := mailSenderManifestWithQueue("mail-sends") - reader := Manifest{Module: "reader", Version: "1", Consumes: []string{"mesh-mail.mail.send.approved"}} - got := strings.Join(WorkQueueConsumedElsewhereProblems(Shelf{"mesh-mail": sender, "reader": reader}), "; ") - if !strings.Contains(got, "reader") || !strings.Contains(got, "MAIL_SENDS") { - t.Fatalf("another module's consumes of a work-queue stream: %q", got) - } -} diff --git a/go/catalogue/zones.go b/go/catalogue/zones.go index a3a938b..7c4b4a2 100644 --- a/go/catalogue/zones.go +++ b/go/catalogue/zones.go @@ -33,35 +33,6 @@ type ZoneAt struct { Port int } -// zoneProblems is what is wrong with a module's zone declaration on its own, before any node. -func zoneProblems(m Manifest) []string { - if m.Zone == nil { - return nil - } - var problems []string - if strings.TrimSpace(m.Zone.Name) == "" { - problems = append(problems, fmt.Sprintf("%s declares a zone with no name", m.Module)) - } - if !m.hasListen(m.Zone.Listen) { - problems = append(problems, fmt.Sprintf( - "%s declares zone %q answered by listen %q, and has no listen of that name", - m.Module, m.Zone.Name, m.Zone.Listen)) - } - return problems -} - -func (m Manifest) hasListen(name string) bool { - if name == "" { - return false - } - for _, l := range m.Listens { - if l.Name == name { - return true - } - } - return false -} - // ZoneOn is one module's zone as one node places it: the name settled from the node's settings, the // port its answering listen is published on there. Nothing when the module declares no zone. func ZoneOn(m Manifest, layers []Layer, published map[int]int, node, address string) (*ZoneAt, error) { diff --git a/go/catalogue/zones_test.go b/go/catalogue/zones_test.go index 515c0cb..e3a1181 100644 --- a/go/catalogue/zones_test.go +++ b/go/catalogue/zones_test.go @@ -5,21 +5,6 @@ import ( "testing" ) -// A zone names the listen that answers it, or there is nothing to forward to (novox/hq ADR 0199). -func TestAZoneMustNameOneOfTheModulesListens(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"lab","version":"1", - "listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}], - "zone":{"name":"${setting:zone}","listen":"web"}}`)) - if err == nil || !strings.Contains(err.Error(), `has no listen of that name`) { - t.Fatalf("a zone answered by a listen the module does not have was accepted: %v", err) - } - if _, err := ParseManifest([]byte(`{"module":"lab","version":"1", - "listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}], - "zone":{"name":"${setting:zone}","listen":"dns"}}`)); err != nil { - t.Fatalf("a well-formed zone was refused: %v", err) - } -} - // The zone is the operator's (a setting) and the port is where this machine publishes the listen — // neither is the definition's to state. func TestAZoneIsPlacedFromTheNodesSettingAndPublishedPort(t *testing.T) { -- 2.54.0 From 4dfc11b42431627d19ed4e1643dff7860084fbbd Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 02:38:05 +0100 Subject: [PATCH 09/13] =?UTF-8?q?Take=20the=20reserved=20stream=20prefixes?= =?UTF-8?q?=20out=20of=20mesh-sdk=20with=20the=20judge=20that=20reads=20th?= =?UTF-8?q?em=20(ADR=200578=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/streams.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/go/catalogue/streams.go b/go/catalogue/streams.go index 2aa8c9b..64fc46c 100644 --- a/go/catalogue/streams.go +++ b/go/catalogue/streams.go @@ -42,9 +42,6 @@ const ( StreamMostDeliveries = 10 ) -// ReservedStreamPrefixes are the starts of the stream names the mesh derives, which no module's stream -// may take: a seat's work queue, a log, a bucket, an object store, and the dead letters. -var ReservedStreamPrefixes = []string{"SEAT_", "LOG_", "KV_", "OBJ_", "DEAD_"} // MeshStreamNames are the mesh's own streams by name. Written here as well as in the broker, which this // package cannot import; a test keeps them agreeing. -- 2.54.0 From a20bdf795236c744763ba7d58a031d69383e94c7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 01:57:26 +0100 Subject: [PATCH 10/13] Carry mesh package versions in build asks and fingerprints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0573 §2 has every module build against the latest published version of each mesh package it uses, chosen by the planner and written by the build, never pinned by hand (issue 1585, S4). The ask, the fetch request and the merge preview now carry the packages and their versions, and the new-form fingerprint takes each version, so a package's trunk merge changes every dependent's fingerprint. A module using no package keeps its fingerprint. Folded onto feat/judge-once by builder-1585a: Module keeps both Packages and the verdict fields. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buildstep/askrun.go | 21 +++++- go/buildstep/askrun_test.go | 18 ++++-- go/buildstep/request.go | 48 ++++++++++++++ go/buildstep/request_test.go | 11 +++- go/buildstep/trees.go | 5 ++ go/buildstep/trees_test.go | 8 ++- go/delivery/fingerprint2.go | 27 +++++++- go/delivery/fingerprint2_test.go | 14 ++++ go/delivery/forge_inputs_test.go | 32 +++++++++ go/delivery/packages.go | 107 +++++++++++++++++++++++++++++++ go/delivery/preview.go | 2 + go/delivery/source.go | 41 +++++++++++- go/delivery/stepasks.go | 29 +++++++++ go/delivery/stepasks_test.go | 15 ++++- 14 files changed, 356 insertions(+), 22 deletions(-) create mode 100644 go/delivery/packages.go diff --git a/go/buildstep/askrun.go b/go/buildstep/askrun.go index c9fa172..496019b 100644 --- a/go/buildstep/askrun.go +++ b/go/buildstep/askrun.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "path" "slices" "sort" @@ -42,14 +43,30 @@ type AskSteps struct { // RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer. func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) { if ask.Build != nil { - return steps.build(ctx, *ask.Build) + job := *ask.Build + job.Fetch = withPackages(job.Fetch, ask.Packages) + return steps.build(ctx, job) } if ask.Check != nil { - return steps.check(ctx, *ask.Check) + job := *ask.Check + job.Fetch = withPackages(job.Fetch, ask.Packages) + return steps.check(ctx, job) } return nil, Refuse("the ask %s holds no job", ask.ID) } +// withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module +// writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that +// fetches nothing, is left as it is. +func withPackages(fetch map[string]any, packages delivery.PackageVersions) map[string]any { + if len(packages) == 0 || fetch == nil { + return fetch + } + out := maps.Clone(fetch) + out["packages"] = maps.Clone(packages) + return out +} + func raw(v any) (json.RawMessage, error) { if r, ok := v.(json.RawMessage); ok { return r, nil diff --git a/go/buildstep/askrun_test.go b/go/buildstep/askrun_test.go index a2dbefe..a21dd88 100644 --- a/go/buildstep/askrun_test.go +++ b/go/buildstep/askrun_test.go @@ -74,15 +74,21 @@ func TestRunAskBuilds(t *testing.T) { steps := r.steps() var released []string steps.Release = func(ws string) error { released = append(released, ws); return nil } - out, err := RunAsk(context.Background(), steps, ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true, + // Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were. + a := ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, - Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}})) + Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}) + a.Packages = delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit} + out, err := RunAsk(context.Background(), steps, a) if err != nil { t.Fatal(err) } + if _, put := a.Build.Fetch["packages"]; put { + t.Error("the ask's fetch arguments were changed") + } got := strings.Join(r.calls, "\n") want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"} -fetch {"commit":"` + runCommit + `","repository":"novox/x"} +fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"repository":"novox/x"} build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"} build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}` if got != want { @@ -91,9 +97,9 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}` if len(released) != 1 || released[0] != "/w/1" { t.Errorf("released %v, want the one workspace", released) } - a := out.(delivery.BuildAnswer) - if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" { - t.Fatalf("answer %+v", a) + answer := out.(delivery.BuildAnswer) + if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" { + t.Fatalf("answer %+v", answer) } } diff --git a/go/buildstep/request.go b/go/buildstep/request.go index c0189dc..345ef00 100644 --- a/go/buildstep/request.go +++ b/go/buildstep/request.go @@ -2,9 +2,12 @@ package buildstep import ( "fmt" + "maps" "path" "regexp" "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) var ( @@ -27,6 +30,10 @@ type FetchRequest struct { Base string `json:"base,omitempty"` Dir string `json:"dir,omitempty"` Paths []string `json:"paths,omitempty"` + // Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages, + // novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version + // the repository names, checksum-verified, and names them in its log. + Packages delivery.PackageVersions `json:"packages,omitempty"` } // FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input. @@ -35,6 +42,8 @@ func FetchRequestProperties() map[string]any { "dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"}, "paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"}, + "packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, + "description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"}, } } @@ -49,6 +58,12 @@ func (r FetchRequest) Args() map[string]any { if len(r.Paths) > 0 { args["paths"] = append([]string(nil), r.Paths...) } + if len(r.Packages) > 0 { + args["packages"] = map[string]any{} + for p, v := range r.Packages { + args["packages"].(map[string]any)[p] = v + } + } return args } @@ -78,9 +93,42 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) { default: return FetchRequest{}, fmt.Errorf("paths is a list of strings") } + packages, err := PackagesArg(args["packages"]) + if err != nil { + return FetchRequest{}, err + } + r.Packages = packages return r, r.Check() } +// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives; +// nil for none. +func PackagesArg(v any) (delivery.PackageVersions, error) { + out := delivery.PackageVersions{} + switch v := v.(type) { + case nil: + return nil, nil + case delivery.PackageVersions: + maps.Copy(out, v) + case map[string]string: + maps.Copy(out, v) + case map[string]any: + for p, version := range v { + s, ok := version.(string) + if !ok { + return nil, fmt.Errorf("packages is an object of versions (strings), by package") + } + out[p] = s + } + default: + return nil, fmt.Errorf("packages is an object of versions (strings), by package") + } + if len(out) == 0 { + return nil, nil + } + return out, out.Check() +} + // Check is what ParseFetchRequest refuses. func (r FetchRequest) Check() error { if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") { diff --git a/go/buildstep/request_test.go b/go/buildstep/request_test.go index 6e13c8f..69102f7 100644 --- a/go/buildstep/request_test.go +++ b/go/buildstep/request_test.go @@ -10,11 +10,15 @@ import ( // from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495 // clause 1). The request survives the trip through a tool call's arguments. func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) { + // And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4). req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head", - Dir: ".", Paths: []string{"package.json", "src"}} + Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}} args := req.Args() - // A tool call's arguments arrive as JSON: lists of any. + // A tool call's arguments arrive as JSON: lists of any, objects of any. args["paths"] = []any{"package.json", "src"} + if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) { + t.Fatalf("the packages are not in the call's arguments: %v", args["packages"]) + } got, err := ParseFetchRequest(args) if err != nil { t.Fatal(err) @@ -42,6 +46,9 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) { {map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"}, {map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"}, {map[string]any{"dir": "modules/a"}, "names a module directory and no path"}, + {map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"}, + {map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"}, + {map[string]any{"packages": []any{"x"}}, "packages is an object"}, } { args := map[string]any{} for k, v := range ok { diff --git a/go/buildstep/trees.go b/go/buildstep/trees.go index b63065f..49a8f24 100644 --- a/go/buildstep/trees.go +++ b/go/buildstep/trees.go @@ -25,6 +25,8 @@ type ModuleTree struct { // Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree // is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise. Paths map[string]string `json:"paths,omitempty"` + // Packages are the mesh packages the directory's go.sum names (delivery.MeshPackagesOf, novox/hq ADR 0573 §2). + Packages []string `json:"packages,omitempty"` } var lockFiles = []string{"go.sum", "package-lock.json"} @@ -67,6 +69,9 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co mt.Locks = map[string]string{} } mt.Locks[lock] = sha256Hex(raw) + if lock == "go.sum" { + mt.Packages = delivery.MeshPackagesOf(raw) // the mesh packages it uses (novox/hq ADR 0573 §2) + } } } trees[mdir] = mt diff --git a/go/buildstep/trees_test.go b/go/buildstep/trees_test.go index 9cbbf0b..a8b1a90 100644 --- a/go/buildstep/trees_test.go +++ b/go/buildstep/trees_test.go @@ -33,7 +33,8 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T) writeTreeFile(t, root, "module.json", `{"root":1}`) writeTreeFile(t, root, "go.sum", "rootsum") writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`) - writeTreeFile(t, root, "modules/a/go.sum", "asum") + asum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:x=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:y=\ngithub.com/nats-io/nats.go v1.0.0 h1:z=\n" + writeTreeFile(t, root, "modules/a/go.sum", asum) writeTreeFile(t, root, "modules/a/package-lock.json", "alock") writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`) merge := strings.Repeat("c", 40) @@ -55,8 +56,9 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T) t.Fatal(err) } want := map[string]ModuleTree{ - ".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}}, - "modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}}, + ".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}}, + "modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf(asum), "package-lock.json": hexOf("alock")}, + Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}, // issue 1585: the mesh packages its go.sum names "modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)}, } if !reflect.DeepEqual(got, want) { diff --git a/go/delivery/fingerprint2.go b/go/delivery/fingerprint2.go index 23a991a..847d973 100644 --- a/go/delivery/fingerprint2.go +++ b/go/delivery/fingerprint2.go @@ -11,6 +11,10 @@ import ( // SourcePrefix2 opens every source fingerprint of the new form. const SourcePrefix2 = "src2:" +// NoPackageVersion opens the reason a module has no fingerprint while a mesh package it uses has no published version +// the indexer holds (novox/hq ADR 0573 §2); the packages follow, by module path. +const NoPackageVersion = "no published version of the package " + // Toolchain2 is what an artifact's toolchain enters the new fingerprint as (novox/hq ADR 0405 clause 7): the // artifact's language, the installed toolchain's version and the build module's recipe version, in place of the // toolchain image's digest. A toolchain update so changes the fingerprints of that language's modules only. @@ -37,15 +41,19 @@ type SourceInputs2 struct { Contexts map[string]string Locks map[string]string Toolchains map[string]Toolchain2 - Unpinned string + // Packages are the version of each mesh package the module uses, by package (novox/hq ADR 0573 §2): a package with + // no version gives no fingerprint. None leaves the fingerprint as it was before packages entered it. + Packages map[string]string + Unpinned string } // SourceFingerprint2 is the module's source fingerprint of the new form, `src2:` and the hex sha256 of its inputs' // lines, or none and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is // empty, "no lock" when an artifact resolves packages and has no lock. It is a pure function of its inputs: the order // of maps and a base given twice change nothing. The lines are "module ", "tree " and "builder ", then -// "base " (once each), "context ", "lock " and "toolchain -// ", sorted, all joined by a newline. +// "base " (once each), "context ", "lock ", "toolchain +// " and "package ", sorted, all joined by a newline. A module that uses no mesh +// package has no package line, so its fingerprint is the one it had before packages entered (novox/hq ADR 0573 §2). func SourceFingerprint2(in SourceInputs2) (fp, none string) { if in.Unpinned != "" { return "", in.Unpinned @@ -58,6 +66,16 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) { return "", "no lock" } } + var unpublished []string + for p, v := range in.Packages { + if v == "" { + unpublished = append(unpublished, p) + } + } + if len(unpublished) > 0 { + sort.Strings(unpublished) + return "", NoPackageVersion + strings.Join(unpublished, ", ") + } lines := []string{"module " + in.Module, "tree " + in.Tree, "builder " + in.Builder} bases := map[string]bool{} for _, b := range in.Bases { @@ -75,6 +93,9 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) { for a, t := range in.Toolchains { lines = append(lines, "toolchain "+a+" "+t.Language+" "+t.Version+" "+t.Recipe) } + for p, v := range in.Packages { + lines = append(lines, "package "+p+" "+v) + } sort.Strings(lines[3:]) sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) return SourcePrefix2 + hex.EncodeToString(sum[:]), "" diff --git a/go/delivery/fingerprint2_test.go b/go/delivery/fingerprint2_test.go index 053b8dc..f53031a 100644 --- a/go/delivery/fingerprint2_test.go +++ b/go/delivery/fingerprint2_test.go @@ -62,6 +62,9 @@ func TestFingerprint2Changes(t *testing.T) { "a context": func(in *SourceInputs2) { in.Contexts["mail"] = "t-changed" }, "a context added": func(in *SourceInputs2) { in.Contexts["extra"] = "t-root" }, "a toolchain added": func(in *SourceInputs2) { in.Toolchains["extra"] = Toolchain2{"go", "1.26.0", "3"} }, + // Issue 1585 (ADR 0573 §2): a package's new published version, and a package used at all. + "a package added": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c1"} }, + "a package version": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c2"} }, } { in := inputs2() f(&in) @@ -111,6 +114,17 @@ func TestFingerprint2None(t *testing.T) { if fp, none := SourceFingerprint2(in); fp != "" || none != "no tree" { t.Errorf("no tree: fp = %q, none = %q", fp, none) } + // Issue 1585: a package with no published version gives none, naming it; an empty set of packages changes nothing. + in = inputs2() + in.Packages = map[string]string{"git.novox.be/novox/b/go": "", "git.novox.be/novox/a/go": "", "git.novox.be/novox/c/go": "c1"} + if fp, none := SourceFingerprint2(in); fp != "" || none != NoPackageVersion+"git.novox.be/novox/a/go, git.novox.be/novox/b/go" { + t.Errorf("no package version: fp = %q, none = %q", fp, none) + } + in = inputs2() + in.Packages = map[string]string{} + if fp, _ := SourceFingerprint2(in); fp != mustFP2(t, inputs2()) { + t.Errorf("no package changes the fingerprint: %s", fp) + } in = inputs2() in.Tree, in.Unpinned = "", "a context is a Go program's build source" if _, none := SourceFingerprint2(in); none != in.Unpinned { diff --git a/go/delivery/forge_inputs_test.go b/go/delivery/forge_inputs_test.go index 4b76ee1..e916159 100644 --- a/go/delivery/forge_inputs_test.go +++ b/go/delivery/forge_inputs_test.go @@ -1,6 +1,7 @@ package delivery import ( + "reflect" "strings" "testing" ) @@ -163,4 +164,35 @@ func TestModuleKeepsInputs2(t *testing.T) { if _, err := EncodeModule(m); err == nil { t.Error("Inputs2 without a manifest digest was encoded") } + + // Issue 1585 (ADR 0573 §2): the mesh packages a go.sum names, those of other repositories, and the latest published + // version of each: the trunk commit of the deepest module of its repository whose directory holds it. + sum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:a=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:b=\n" + + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 h1:c=\n" + + "github.com/nats-io/nats.go v1.0.0 h1:d=\ngit.novox.be/novox/mesh-host v0.0.0-1 h1:e=\n" + pkgs := MeshPackagesOf([]byte(sum)) + if want := []string{"git.novox.be/novox/mesh-controller/modules/route-proxy/contract", "git.novox.be/novox/mesh-host", "git.novox.be/novox/mesh-sdk/go"}; !reflect.DeepEqual(pkgs, want) { + t.Errorf("the packages of a go.sum: %v, want %v", pkgs, want) + } + if got := OtherPackages(pkgs, "novox/mesh-controller"); len(got) != 2 || got[0] != "git.novox.be/novox/mesh-host" { + t.Errorf("the packages of other repositories: %v", got) + } + held := map[string]Module{ + "mesh-sdk": {Repository: "novox/mesh-sdk", Path: RootPath, Commit: "c-sdk"}, + "route-proxy": {Repository: "novox/mesh-controller", Path: "modules/route-proxy", Commit: "c-rp"}, + "controller": {Repository: "novox/mesh-controller", Path: RootPath, Commit: "c-ctl"}, + "route-proxy2": {Repository: "novox/mesh-controller", Path: "modules/route", Commit: "c-wrong"}, + "no-path": {Repository: "novox/mesh-host", Commit: "c-host"}, + } + versions, missing := PackageVersionsOf(pkgs, held) + if want := (PackageVersions{"git.novox.be/novox/mesh-sdk/go": "c-sdk", "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": "c-rp"}); !reflect.DeepEqual(versions, want) { + t.Errorf("versions %v, want %v", versions, want) + } + if len(missing) != 1 || missing[0] != "git.novox.be/novox/mesh-host" { + t.Errorf("missing %v: a module that names no directory holds no package", missing) + } + kept.Packages = []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-host"} + if fp, none := SourceFingerprint2(kept.WithPackages("m", tc, versions)); fp != "" || !strings.HasPrefix(none, NoPackageVersion) { + t.Errorf("a package with no version: %q %q", fp, none) + } } diff --git a/go/delivery/packages.go b/go/delivery/packages.go new file mode 100644 index 0000000..a039f1e --- /dev/null +++ b/go/delivery/packages.go @@ -0,0 +1,107 @@ +package delivery + +import ( + "maps" + "slices" + "strings" +) + +// **Every module builds against the latest published version of each mesh package it uses** (novox/hq ADR 0573 §2; +// issue 1585). A mesh package is a Go module of a repository of the mesh's forge (ForgeModulePrefix): mesh-sdk's Go +// module, and a seat's contract its holder's repository publishes (ADR 0573 §1). Its version is the trunk merge the +// indexer holds for the module whose directory holds the package (PackageVersionsOf): a merge that changes it changes +// the fingerprint of every module that uses it, and the build writes that version into its own go.mod and go.sum +// (StepAsk.Packages). Which packages a module uses is read from its lock file by MeshPackagesOf, the same way by the +// forge bridge and by the build modules (buildstep.ModuleTrees), so nobody names a version by hand. + +// ForgeModulePrefix is the module path prefix of the Go modules the mesh's forge serves: a Go module whose path starts +// with it is a mesh package, git.novox.be/novox/[/]. +const ForgeModulePrefix = "git.novox.be/novox/" + +// MeshPackagesOf are the mesh packages a Go module's go.sum names (the module path of each line that starts with +// ForgeModulePrefix), sorted and each once. go.sum names every module of the build's graph, so a package used only +// through another is named too. Nil for none. A package of the module's own repository comes with its own tree, and +// its reader leaves it out (OtherPackages). +func MeshPackagesOf(goSum []byte) []string { + seen := map[string]bool{} + for _, line := range strings.Split(string(goSum), "\n") { + f := strings.Fields(line) + if len(f) < 2 || !strings.HasPrefix(f[0], ForgeModulePrefix) { + continue + } + if _, _, ok := PackageRepository(f[0]); !ok { + continue + } + seen[f[0]] = true + } + if len(seen) == 0 { + return nil + } + return slices.Sorted(maps.Keys(seen)) +} + +// OtherPackages are the packages not published from repository, the module's own, in their order; nil for none. +func OtherPackages(packages []string, repository string) []string { + var out []string + for _, p := range packages { + if repo, _, ok := PackageRepository(p); ok && !SameRepository(repo, repository) { + out = append(out, p) + } + } + return out +} + +// PackageRepository is the repository (/) a mesh package is published from and the package's directory in +// it ("" for its root); false for a path that is not a forge module's. +func PackageRepository(pkg string) (repository, dir string, ok bool) { + rest, found := strings.CutPrefix(pkg, ForgeModulePrefix) + if !found || rest == "" { + return "", "", false + } + name, dir, _ := strings.Cut(rest, "/") + if name == "" { + return "", "", false + } + return "novox/" + name, dir, true +} + +// PackageVersionsOf is the latest published version of each package from the modules the indexer holds (by key): the +// trunk merge commit of the module of the package's repository whose directory holds the package's directory (the +// deepest, a module at the repository's root holding every directory). A package no held module holds, or whose +// module names no commit or no directory, is in missing, sorted; versions is nil when packages is empty. +func PackageVersionsOf(packages []string, modules map[string]Module) (versions PackageVersions, missing []string) { + for _, pkg := range packages { + repository, dir, ok := PackageRepository(pkg) + commit, depth := "", -1 + if ok { + for _, key := range slices.Sorted(maps.Keys(modules)) { + m := modules[key] + if !SameRepository(m.Repository, repository) || m.Commit == "" { + continue + } + at := strings.Trim(m.Path, "/") + switch { + case at == "": + continue // a value that names no directory says nothing of where the package lies + case at == RootPath: + at = "" + case dir != at && !strings.HasPrefix(dir, at+"/"): + continue + } + if d := len(at); d > depth { + commit, depth = m.Commit, d + } + } + } + if commit == "" { + missing = append(missing, pkg) + continue + } + if versions == nil { + versions = PackageVersions{} + } + versions[pkg] = commit + } + slices.Sort(missing) + return versions, missing +} diff --git a/go/delivery/preview.go b/go/delivery/preview.go index 3679146..2fe3297 100644 --- a/go/delivery/preview.go +++ b/go/delivery/preview.go @@ -107,6 +107,8 @@ type PreviewModule struct { // Paths are, of a module whose every artifact names its paths, each named path's git object id at the merge; Tree // is then their NamedTree (novox/hq ADR 0499 clause 2). Absent otherwise. Paths map[string]string `json:"paths,omitempty"` + // Packages are the mesh packages the module's lock file names at the merge (MeshPackagesOf, novox/hq ADR 0573 §2). + Packages []string `json:"packages,omitempty"` } // Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge diff --git a/go/delivery/source.go b/go/delivery/source.go index e9d28a0..33595a7 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -284,6 +284,10 @@ type CandidateModule struct { // Paths are, of a module whose every artifact names its paths, each named path's git object id at the preview // (novox/hq ADR 0499 clause 2), so the builder's fetch request for the candidate names them. Absent otherwise. Paths map[string]string `json:"paths,omitempty"` + // Packages are the versions of the mesh packages FP2 was computed with (novox/hq ADR 0573 §2, §4): the latest + // published version of each, or the head of the candidate's pull request that publishes it, so a group's candidate + // builds against the group's own package. Absent for none. + Packages PackageVersions `json:"packages,omitempty"` } func (m CandidateModule) check() error { @@ -296,6 +300,9 @@ func (m CandidateModule) check() error { if err := checkPathObjects(m.Paths); err != nil { return err } + if err := m.Packages.Check(); err != nil { + return err + } return checkNewForm(m.FP2, m.Manifest) } @@ -415,6 +422,10 @@ type Module struct { // so the checker tells whether a change touches an artifact's build source without reading a manifest (the lead's // decision L8 of R4e). Absent for an artifact that names none, and in a value written before. ArtifactPaths map[string][]string `json:"paths,omitempty"` + // Packages are the versions of the mesh packages FP2 was computed with (novox/hq ADR 0573 §2): the trunk merge the + // indexer held for each package of Inputs2.Packages. A builder builds the fingerprint against exactly these + // (StepAsk.Packages). Empty when FP2 is empty or the module uses no mesh package. + Packages PackageVersions `json:"packages,omitempty"` // Verdict is the indexer's verdict on Manifest, VerdictAccepted or VerdictRefused, and Problems each problem of a // refusal, as the verdicts state holds them (novox/hq ADR 0578 §1): a reader of modules takes it without a read of // that state. Empty in a value written before the indexer judged, which a reader takes as accepted (TakeManifestVerdict). @@ -446,6 +457,10 @@ type KeptInputs2 struct { // computation from kept inputs gives the merge's value (novox/hq ADR 0499 clause 2). Absent for every other module, // whose Tree is its directory's tree, and in a value written before. Paths map[string]string `json:"paths,omitempty"` + // Packages are the mesh packages the module's lock file names (MeshPackagesOf, novox/hq ADR 0573 §2): the + // fingerprint takes the latest published version of each (Module.Packages). Absent for none, and in a value written + // before. + Packages []string `json:"packages,omitempty"` } // checkPathObjects refuses a named path that is not clean and relative to the module's directory, or names no object. @@ -482,6 +497,19 @@ func (k KeptInputs2) With(module string, toolchains map[string]Toolchain2) Sourc Locks: k.Locks, Toolchains: toolchains} } +// WithPackages is With and the version of each package the module uses (Packages), as PackageVersionsOf read them: a +// package with no version enters with none, and the fingerprint is then none (SourceFingerprint2). +func (k KeptInputs2) WithPackages(module string, toolchains map[string]Toolchain2, versions PackageVersions) SourceInputs2 { + in := k.With(module, toolchains) + if len(k.Packages) > 0 { + in.Packages = map[string]string{} + for _, p := range k.Packages { + in.Packages[p] = versions[p] + } + } + return in +} + func (m Module) check() error { switch { case m.FP != "" && m.None != "": @@ -498,6 +526,9 @@ func (m Module) check() error { return fmt.Errorf("delivery: the module's toolchain of %q needs its version and its recipe version", language) } } + if err := m.Packages.Check(); err != nil { + return err + } if m.Inputs2 != nil && m.Manifest == "" { return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest") } @@ -555,9 +586,13 @@ type Forge struct { // id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The // indexer names the paths; a message without them gives such a module none. Older writers send none. Paths map[string]map[string]string `json:"paths,omitempty"` - Act string `json:"act,omitempty"` - Words string `json:"words,omitempty"` - Open []OpenPull `json:"open,omitempty"` + // Packages are, of each module directory, the mesh packages its lock file names (MeshPackagesOf; novox/hq ADR 0573 + // §2): the indexer keeps them in the module's inputs and takes each one's latest published version into its + // fingerprint. Absent for a directory that uses none, and from an older writer. + Packages map[string][]string `json:"packages,omitempty"` + Act string `json:"act,omitempty"` + Words string `json:"words,omitempty"` + Open []OpenPull `json:"open,omitempty"` // RetiredPlacements is the placement files a bridge before ADR 0567 carried (ADR 0549 §2, ADR 0559 §2): placement is // the seat mesh-placement's state now and no repository holds it (ADR 0567 §11), so a message carrying them is // refused, loudly, rather than read with them dropped. diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index fba438c..17ec656 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -253,6 +253,30 @@ type StepAsk struct { Compose *ComposeJob `json:"compose,omitempty"` // Needs are what the node that runs the ask must have (AskNeeds); a check ask's alone. Empty: any node. Needs []string `json:"needs,omitempty"` + // Packages are the version of each mesh package the build or the check builds against, by its module path (a Go + // module's path, git.novox.be/novox/mesh-sdk/go; the shape takes a seat's package too), whatever version the + // repository's go.mod names (novox/hq ADR 0573 §2, §4; issue 1585): a Go package's version is a full commit, the + // trunk merge the indexer holds as the package's latest published version or, in a delivery group's check, the + // head of the group's member that publishes it. The build module writes each into its workspace, checksum-verified, + // and names them in its log (PackageVersions). A compose takes none: it builds the mesh-sdk its reader names. + Packages PackageVersions `json:"packages,omitempty"` +} + +// PackageVersions are the versions of the mesh packages a build or a check builds against, by package (a Go module's +// path), each a full commit (novox/hq ADR 0573 §2). +type PackageVersions map[string]string + +// Check refuses a package that is no module path or a version that is no full commit, naming it. +func (p PackageVersions) Check() error { + for pkg, version := range p { + if pkg == "" || strings.ContainsAny(pkg, " \t\n@") || strings.HasPrefix(pkg, "/") { + return fmt.Errorf("delivery: the package %q is no module path", pkg) + } + if !askCommitOK.MatchString(version) { + return fmt.Errorf("delivery: the package %s names the version %q, which is no full commit", pkg, version) + } + } + return nil } // ComposeJob is a compose ask's job (novox/hq ADR 0482 clause 4, issue 1622): fetch the commit, build the composer @@ -389,6 +413,11 @@ func (a StepAsk) check() error { return fmt.Errorf("delivery: the ask %s is a check of an image, which has no tests", a.ID) case len(a.Needs) > 0 && a.Check == nil: return fmt.Errorf("delivery: the ask %s names needs and is no check, whose tests alone need a node", a.ID) + case len(a.Packages) > 0 && a.Compose != nil: + return fmt.Errorf("delivery: the ask %s is a compose and names package versions, which a compose does not take", a.ID) + } + if err := a.Packages.Check(); err != nil { + return fmt.Errorf("the ask %s: %w", a.ID, err) } for _, n := range a.Needs { if err := CheckNeed(n); err != nil { diff --git a/go/delivery/stepasks_test.go b/go/delivery/stepasks_test.go index 99269cf..6f1acd1 100644 --- a/go/delivery/stepasks_test.go +++ b/go/delivery/stepasks_test.go @@ -17,7 +17,8 @@ func buildAsk() StepAsk { Build: &BuildJob{Module: "mesh-delivery-view", Path: "modules/mesh-delivery-view", FP: "ab12", Fetch: map[string]any{"repository": "novox/mesh-delivery", "commit": askCommit, "dir": "modules/mesh-delivery-view", "paths": []any{"cmd"}}, Artifacts: []AskArtifact{{Artifact: "bundle"}, {Artifact: "base", From: "registry/x@sha256:00"}}, - Publish: true, Toolchain: &ToolchainUsed{Version: "go1.26.0", Recipe: "3"}}} + Publish: true, Toolchain: &ToolchainUsed{Version: "go1.26.0", Recipe: "3"}}, + Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit}} } func checkAsk() StepAsk { @@ -25,10 +26,11 @@ func checkAsk() StepAsk { At: time.Date(2026, 10, 23, 13, 0, 0, 0, time.UTC), Check: &CheckJob{Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit, "ref": "refs/pull/3/head", "base": askCommit}, Levels: []string{LevelUnit, LevelIntegration}, Kept: true, Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Run: "", - NodeNames: []string{"anchor", "laptop"}}} + NodeNames: []string{"anchor", "laptop"}}, + Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit, "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": askCommit}} } -// 1887/A1a: an ask encodes and decodes with every field. +// 1887/A1a: an ask encodes and decodes with every field; 1585/S4: the package versions too. func TestAskRoundTrips(t *testing.T) { for _, a := range []StepAsk{buildAsk(), checkAsk()} { raw, err := EncodeStepAsk(a) @@ -58,6 +60,13 @@ func TestAskRefusesUnknownKindAndLanguage(t *testing.T) { "commit": func(a *StepAsk) { a.Build.Fetch = map[string]any{"repository": "novox/x", "commit": "main"} }, "path": func(a *StepAsk) { a.Build.Path = "" }, "needs": func(a *StepAsk) { a.Needs = []string{NeedNodeBuildNode} }, // issue 890: a build needs no node + // Issue 1585: a package's version is a full commit, of a module path. + "version": func(a *StepAsk) { a.Packages["git.novox.be/novox/mesh-sdk/go"] = "v0.1.7" }, + "package": func(a *StepAsk) { a.Packages["git.novox.be/novox/x y"] = askCommit }, + "compose": func(a *StepAsk) { + a.Kind, a.Build = AskCheck, nil + a.Compose = &ComposeJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": askCommit}, Dir: "go", Package: "x", Request: json.RawMessage(`{}`)} + }, } for name, change := range cases { a := buildAsk() -- 2.54.0 From 19c204273bdc6565c5bc0e9bf8c8f767c135c0d8 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 02:43:10 +0100 Subject: [PATCH 11/13] =?UTF-8?q?Let=20a=20verdict=20warn=20of=20what=20a?= =?UTF-8?q?=20stricter=20judge=20found=20in=20a=20manifest=20it=20accepted?= =?UTF-8?q?=20before=20(ADR=200579=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1585: ManifestVerdict and the modules value carry Warnings; the finding manifest-outgrown names it. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/source.go | 10 +++++++++- go/delivery/verdicts.go | 30 +++++++++++++++++------------- go/delivery/verdicts_test.go | 10 ++++++++++ 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/go/delivery/source.go b/go/delivery/source.go index 33595a7..a627671 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -431,13 +431,19 @@ type Module struct { // that state. Empty in a value written before the indexer judged, which a reader takes as accepted (TakeManifestVerdict). Verdict string `json:"verdict,omitempty"` Problems []string `json:"problems,omitempty"` + // 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"` } // VerdictOf is the verdict a modules value carries, or nil when it carries none (taken as accepted, TakeManifestVerdict). func (m Module) VerdictOf(module string) *ManifestVerdict { - if m.Verdict == "" { + if m.Verdict == "" && len(m.Warnings) == 0 { return nil } + if m.Verdict == "" { + return &ManifestVerdict{Module: module, State: VerdictAccepted, Warnings: m.Warnings} + } return &ManifestVerdict{Module: module, State: m.Verdict, Problems: m.Problems} } @@ -554,6 +560,8 @@ func (m Module) check() error { return fmt.Errorf("delivery: a module carries a verdict and no manifest digest") case (m.Verdict == VerdictRefused) != (len(m.Problems) > 0): return fmt.Errorf("delivery: 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("delivery: a refused module warns: a refusal names problems") } return checkNewForm(m.FP2, m.Manifest) } diff --git a/go/delivery/verdicts.go b/go/delivery/verdicts.go index 86de134..15a6a9c 100644 --- a/go/delivery/verdicts.go +++ b/go/delivery/verdicts.go @@ -3,6 +3,7 @@ package delivery import ( "encoding/json" "fmt" + "slices" "time" ) @@ -26,15 +27,23 @@ const ( // ManifestRefusedEvent is the indexer's event saying a manifest was refused (ManifestRefused), so the watchman // raises it as a condition beside the indexer's finding. ManifestRefusedEvent = "manifest-refused" + + // ManifestOutgrown is the finding of a manifest the indexer accepted before and a stricter judge finds problems in + // (novox/hq ADR 0579 §1): it stays accepted, its problems are the verdict's Warnings, and the watchman raises it as a + // warning until the manifest is mended. + ManifestOutgrown = "manifest-outgrown" ) // ManifestVerdict is the value of a verdicts key: the module whose manifest it is, accepted or refused, every problem the judge // found (none when accepted), the judge that made it (the indexer's build fingerprint) and when. type ManifestVerdict struct { - V int `json:"v"` - Module string `json:"module"` - State string `json:"state"` - Problems []string `json:"problems,omitempty"` + V int `json:"v"` + Module string `json:"module"` + State string `json:"state"` + Problems []string `json:"problems,omitempty"` + // Warnings are the problems a stricter judge found in a manifest it accepted before, digest unchanged (novox/hq ADR + // 0579 §1): the verdict stays accepted. + Warnings []string `json:"warnings,omitempty"` Judge string `json:"judge"` At time.Time `json:"at"` } @@ -45,15 +54,7 @@ func (v ManifestVerdict) Refused() bool { return v.State == VerdictRefused } // Same is whether v and o say the same of a manifest: the module, the state and the problems, whoever judged and when. // The indexer writes a verdict only when it is not the same as the one it holds. func (v ManifestVerdict) Same(o ManifestVerdict) bool { - if v.Module != o.Module || v.State != o.State || len(v.Problems) != len(o.Problems) { - return false - } - for i := range v.Problems { - if v.Problems[i] != o.Problems[i] { - return false - } - } - return true + return v.Module == o.Module && v.State == o.State && slices.Equal(v.Problems, o.Problems) && slices.Equal(v.Warnings, o.Warnings) } func (v ManifestVerdict) check() error { @@ -69,6 +70,9 @@ func (v ManifestVerdict) check() error { if len(v.Problems) == 0 { return fmt.Errorf("delivery: the verdict on %s refuses its manifest and names no problem", v.Module) } + if len(v.Warnings) > 0 { + return fmt.Errorf("delivery: the verdict on %s refuses its manifest and warns: a refusal names problems", v.Module) + } default: return fmt.Errorf("delivery: the verdict on %s is %q, neither %s nor %s", v.Module, v.State, VerdictAccepted, VerdictRefused) } diff --git a/go/delivery/verdicts_test.go b/go/delivery/verdicts_test.go index 0de3517..da05519 100644 --- a/go/delivery/verdicts_test.go +++ b/go/delivery/verdicts_test.go @@ -28,6 +28,7 @@ func TestAManifestVerdictIsTheContractReadersTake(t *testing.T) { "another state": {Module: "m", State: "maybe", Judge: "j"}, "refused, no problem": {Module: "m", State: VerdictRefused, Judge: "j"}, "accepted, a problem": {Module: "m", State: VerdictAccepted, Problems: []string{"x"}, Judge: "j"}, + "refused, a warning": {Module: "m", State: VerdictRefused, Problems: []string{"x"}, Warnings: []string{"y"}, Judge: "j"}, } { if _, err := EncodeManifestVerdict(bad); err == nil { t.Errorf("%s: encoded", name) @@ -62,4 +63,13 @@ func TestAManifestVerdictIsTheContractReadersTake(t *testing.T) { if _, err := EncodeModule(m); err == nil { t.Fatal("a module refused naming no problem was encoded") } + // ADR 0579 §1: an accepted manifest a stricter judge outgrew carries its warnings and is taken as accepted. + outgrown := ManifestVerdict{Module: "x", State: VerdictAccepted, Warnings: []string{"w"}, Judge: "j"} + if _, err := EncodeManifestVerdict(outgrown); err != nil || outgrown.Same(ManifestVerdict{Module: "x", State: VerdictAccepted}) { + t.Fatalf("an outgrown verdict: %v", err) + } + m.Verdict, m.Warnings = "", []string{"w"} + if r, _ := TakeManifestVerdict(m.VerdictOf("x")); r || len(m.VerdictOf("x").Warnings) != 1 { + t.Fatalf("an outgrown module: %+v", m.VerdictOf("x")) + } } -- 2.54.0 From 62cddf4efc62c13140ebd5731e0121f7ca69e7d1 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 03:07:50 +0100 Subject: [PATCH 12/13] =?UTF-8?q?Refuse=20a=20manifest=20judge=20in=20mesh?= =?UTF-8?q?-sdk's=20own=20check,=20and=20never=20end=20the=20data=20items'?= =?UTF-8?q?=20read=20on=20one=20module=20(ADR=200578=20=C2=A73,=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (issue 1585): L1, DataItemsHeld holds a module by the culprit rule PlansHeld uses, a held module's data left out, so an item its composition refuses holds that module alone; L2, mesh-sdk's own tests scan its go/ tree as mesh-catalog's checks/sdkjudge does, with the same list of composition's refusals. Two tests merged into one. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/setting_defaults_test.go | 3 +- go/compose/frombus/frombus.go | 43 ++++- .../frombus/frombustest/frombustest_test.go | 27 ++- go/judge_free_test.go | 175 ++++++++++++++++++ 4 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 go/judge_free_test.go diff --git a/go/catalogue/setting_defaults_test.go b/go/catalogue/setting_defaults_test.go index 7b08f4b..1d6489f 100644 --- a/go/catalogue/setting_defaults_test.go +++ b/go/catalogue/setting_defaults_test.go @@ -31,6 +31,7 @@ func parsed(t *testing.T, m Manifest) error { } func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) { + t.Run("the node over the mesh over the default", theNodeOverTheMeshOverTheDefault) m := notifier() for _, layers := range [][]Layer{nil, {{From: MeshWideLayer, Values: map[string]any{}}}} { file := map[string]any{} @@ -49,7 +50,7 @@ func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) { } } -func TestTheNodeOverTheMeshOverTheDefault(t *testing.T) { +func theNodeOverTheMeshOverTheDefault(t *testing.T) { m := notifier() layers := []Layer{ {From: MeshWideLayer, Values: map[string]any{"width": float64(300)}}, diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 1bc385a..a74764f 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -545,10 +545,16 @@ func PlansHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (plans map[str // plansHeldOf is the plans of node over in, every module in held held, and each module a refusal is of held too until // the plans compose (culprit's rule over the Planning mode: its stub first, then its bare stub). func plansHeldOf(in compose.Input, node string, held map[string]string) (map[string]catalogue.Plan, error) { + return heldOf(in, node, held, func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) }) +} + +// heldOf composes compose1 over in with every module in held held, and each module a refusal is of held too until it +// composes (culprit's rule: its stub first, then its bare stub); what compose1 answers of a held module is left out. +// Plans and data items are composed so (PlansHeld, DataItemsHeld): one module never ends the read (novox/hq ADR 0578 §3). +func heldOf[V any](in compose.Input, node string, held map[string]string, compose1 func(compose.Input) (map[string]V, error)) (map[string]V, error) { for _, m := range sortedKeys(held) { in = stubbed(in, node, m, "") } - compose1 := func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) } // Each try holds one module more or leaves out all of one held module's offer: bounded by twice the modules placed. for tries := 0; tries <= 2*len(assignedOn(in, node))+1; tries++ { plans, err := compose1(in) @@ -585,7 +591,7 @@ func plansHeldOf(in compose.Input, node string, held map[string]string) (map[str } in = stubbed(in, node, module, leave) } - return nil, fmt.Errorf("frombus: the plans of %s do not compose with %d module(s) held: %s", node, len(held), + return nil, fmt.Errorf("frombus: what %s composes does not compose with %d module(s) held: %s", node, len(held), strings.Join(sortedKeys(held), ", ")) } @@ -1057,9 +1063,34 @@ func DataItemsHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (items map if err != nil { return nil, nil, held, err } - items, unplaced, err = compose.DataItems(in, s.Node) - for m := range held { - delete(items, m) - } + // Held module by module, as PlansHeld holds them: a refused stub or one module's composition refusal (an item's + // template) holds that module and never ends the read. + items, err = heldOf(in, s.Node, held, func(in compose.Input) (map[string][]catalogue.PlacedDataItem, error) { + var err error + items, unplaced, err = compose.DataItems(withoutHeldData(in, s.Node), s.Node) + return items, err + }) return items, unplaced, held, err } + +// withoutHeldData is in with no data item of a module held on node: none of a held module's items is measured, so its +// data never refuses the rest (its stub keeps its data for the backups the plans compose). +func withoutHeldData(in compose.Input, node string) compose.Input { + out := in + out.Nodes = append([]compose.Node(nil), in.Nodes...) + for i := range out.Nodes { + n := &out.Nodes[i] + if n.Name != node || len(n.Held) == 0 { + continue + } + kept := maps.Clone(n.Kept) + for m := range n.Held { + if man, ok := kept[m]; ok { + man.Data = nil + kept[m] = man + } + } + n.Kept = kept + } + return out +} diff --git a/go/compose/frombus/frombustest/frombustest_test.go b/go/compose/frombus/frombustest/frombustest_test.go index 5d6d751..0db70b2 100644 --- a/go/compose/frombus/frombustest/frombustest_test.go +++ b/go/compose/frombus/frombustest/frombustest_test.go @@ -29,16 +29,25 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { // refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds // backup too, and never ends the read. refused string + // dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build, + // an item composition refuses), not one only a plan meets. + dataHeld bool + // dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item. + dataOnly bool }{ {name: "every module composes"}, {name: "a built manifest that cannot be used", bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, - built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup"}, + built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true}, {name: "a plan that refuses", bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` + `"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"}, {name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`, - refused: `"shows" is none of them`}, + refused: `"shows" is none of them`, dataHeld: true}, + // ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read. + {name: "a data item its composition refuses", + bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`, + why: "nope", dataHeld: true, dataOnly: true}, {name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true, refused: `"shows" is none of them`}, } { @@ -65,6 +74,13 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest, Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}} } + if row.dataOnly { + items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 { + t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err) + } + continue + } plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) switch { case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")): @@ -111,6 +127,13 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { if _, planned := plans["backup"]; planned { t.Errorf("%s: the held module has a plan", row.name) } + // The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole. + items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + // A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file. + mustHold := row.dataHeld + if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil { + t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err) + } } } diff --git a/go/judge_free_test.go b/go/judge_free_test.go new file mode 100644 index 0000000..86bb607 --- /dev/null +++ b/go/judge_free_test.go @@ -0,0 +1,175 @@ +// The same scan as mesh-catalog's checks/sdkjudge, run by mesh-sdk's own check (novox/hq ADR 0578 §4: the change that +// adds a manifest judge to mesh-sdk is refused), so a change to mesh-sdk alone is refused too. Copied from mesh-catalog +// feat/judge-once checks/sdkjudge/main.go; the two lists of composition's refusals are kept the same. +package stdio + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// composition is every function or table the check lets stay in mesh-sdk, as "path-from-go/:name", and why. It names no +// manifest judgement: each is the composer's refusal of composition itself (ADR 0578 clause 5). +var composition = map[string]string{ + "catalogue/consumers.go:consumersPlaceholderProblems": "composition: rendering a consumer's template", + "catalogue/collections.go:collectedPlaceholderProblems": "composition: placeholders a contribution renders", + "catalogue/environment_into.go:placeholderProblems": "composition: placeholders a contribution renders", + "catalogue/seat_contributions.go:seatPlaceholderProblems": "composition: placeholders a contribution renders", + "catalogue/collections.go:itemTemplateProblem": "composition: filling an item's template", + "catalogue/setting_takes.go:takenSettingsProblems": "composition: whether stored settings still compose", + "catalogue/template.go:HeldTemplateProblem": "composition: the take-in refusal of a resolved build", +} + +// judgeProblems is what mesh-sdk's Go tree under root (its go/ directory) holds of a manifest judge, one line each, but for +// what composition lets stay (the map the caller gives; the test of the real tree gives the package's), and the +// entries of composition it no longer holds. +func judgeProblems(root string, composition map[string]string) ([]string, error) { + var out []string + seen := map[string]bool{} + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if n := d.Name(); n == "testdata" || n == "vendor" || strings.HasPrefix(n, ".") { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, _ := filepath.Rel(root, path) + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution) + if err != nil { + return fmt.Errorf("%s: %w", rel, err) + } + inCatalogue := filepath.Base(filepath.Dir(path)) == "catalogue" + for _, d := range file.Decls { + switch d := d.(type) { + case *ast.FuncDecl: + if why := judgeFunc(d); why != "" { + out = append(out, note(composition, seen, rel, d.Name.Name, why)) + } + case *ast.GenDecl: + if !inCatalogue || d.Tok != token.VAR { + continue + } + for _, spec := range d.Specs { + v, _ := spec.(*ast.ValueSpec) + if v == nil { + continue + } + for i, name := range v.Names { + if i < len(v.Values) && allowName.MatchString(name.Name) && isTable(v.Values[i]) { + out = append(out, note(composition, seen, rel, name.Name, + "a table of what a manifest may declare (an allow-list)")) + } + } + } + } + } + return nil + }) + if err != nil { + return nil, err + } + var kept []string + for _, line := range out { + if line != "" { + kept = append(kept, line) + } + } + for key, why := range composition { + if !seen[key] { + kept = append(kept, fmt.Sprintf("composition lists %s (%s), which mesh-sdk no longer holds: take it off the list", key, why)) + } + } + sort.Strings(kept) + return kept, nil +} + +// note is the line refusing name in rel for why, or "" when composition lets it stay. +func note(composition map[string]string, seen map[string]bool, rel, name, why string) string { + key := rel + ":" + name + if _, ok := composition[key]; ok { + seen[key] = true + return "" + } + return fmt.Sprintf("%s: %s is %s; mesh-sdk carries the contract only, the judge is the indexer's and the checker's (ADR 0578 §4)", rel, name, why) +} + +// allowName is what names a table of what is allowed. +var allowName = regexp.MustCompile(`(?i)allow|permit|removable|reserved|forbid|denied`) + +// isTable says whether an expression is a map or a list literal of more than one entry. +func isTable(e ast.Expr) bool { + c, ok := e.(*ast.CompositeLit) + if !ok || len(c.Elts) < 2 { + return false + } + switch c.Type.(type) { + case *ast.MapType, *ast.ArrayType: + return true + } + return false +} + +// judgeFunc says why a function is a manifest judge, or "". +func judgeFunc(f *ast.FuncDecl) string { + name := f.Name.Name + if judgeEntry.MatchString(name) { + return "a function that judges a manifest" + } + if !strings.HasSuffix(name, "Problems") && !strings.HasSuffix(name, "Problem") { + return "" + } + if takes(f.Recv) || takes(f.Type.Params) { + return "a function that gathers a manifest's problems" + } + return "" +} + +// judgeEntry is the names of the judge's entry points: the strict parsers and the validators of a manifest or a shelf. +var judgeEntry = regexp.MustCompile(`^(ParseManifest(With|ForInputs)?|Validate(Manifest|Shelf)\w*)$`) + +// takes says whether a list of parameters names a Manifest or a Shelf anywhere in a type. +func takes(fields *ast.FieldList) bool { + if fields == nil { + return false + } + found := false + for _, f := range fields.List { + ast.Inspect(f.Type, func(n ast.Node) bool { + if id, ok := n.(*ast.Ident); ok && (id.Name == "Manifest" || id.Name == "Shelf") { + found = true + } + return !found + }) + } + return found +} + +// mesh-sdk holds no manifest judge: no function gathering a manifest's problems, no judge entry point and no allow-list +// table in the catalogue, but for composition's own refusals, each listed with why (ADR 0578 §4, §5). +func TestMeshSDKHoldsNoManifestJudge(t *testing.T) { + root, err := filepath.Abs(".") + if err != nil { + t.Fatal(err) + } + problems, err := judgeProblems(root, composition) + if err != nil { + t.Fatal(err) + } + for _, p := range problems { + t.Error(p) + } +} -- 2.54.0 From 8af6e72669151bf9b4f7524d38c28e29215514c6 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 03:22:06 +0100 Subject: [PATCH 13/13] Format streams.go as gofmt writes it, which the group's check refused Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/streams.go | 1 - 1 file changed, 1 deletion(-) diff --git a/go/catalogue/streams.go b/go/catalogue/streams.go index 64fc46c..86cb60f 100644 --- a/go/catalogue/streams.go +++ b/go/catalogue/streams.go @@ -42,7 +42,6 @@ const ( StreamMostDeliveries = 10 ) - // MeshStreamNames are the mesh's own streams by name. Written here as well as in the broker, which this // package cannot import; a test keeps them agreeing. var MeshStreamNames = []string{"CONTROL", "NODES", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED", "CHECKS_READY", "CHECKS_OTHER"} -- 2.54.0