From 86eb29f2433cbac53daf487eb829cc43a3620b67 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 16:48:51 +0100 Subject: [PATCH 1/9] =?UTF-8?q?A-D29:=20the=20pipeline=20runner=20joins=20?= =?UTF-8?q?the=20validator's=20foundation=20list=20(ADR=200666=20=C2=A74;?= =?UTF-8?q?=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds ADR 0666 §4 (the runner is a foundation part: the bus module's own delivery waits on its pipeline), changing ADR 0619 §1 and §3: judge.Foundation, the one list in code, gains mesh-delivery-pipeline, so its manifest keeps its state in the old sections and a request of new-form bus state by it is refused. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../internal/index/verdicts_test.go | 6 +++--- modules/mesh-delivery-indexer/judge/oldsections.go | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go index 09d6cf52..f8b1ab7e 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go @@ -358,7 +358,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { t.Fatalf("the reads on the owners' state count %v", c.Reads) } } - // DLV-J1 (ADR 0619 §1, §3): a foundation part asking bus state in the new form is refused; the nine are the foundation. + // DLV-J1 (ADR 0619 §1, §3): a foundation part asking bus state in the new form is refused; the nine and the pipeline runner are the foundation. cfg := hold("mesh-config", `{"module":"mesh-config","version":"1","requires":[{"name":"cache","storage":"key-value","template":"state"}]}`) if _, err := ix.JudgeAll(ctx, map[string]bool{cfg: true}); err != nil { t.Fatal(err) @@ -367,8 +367,8 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { t.Fatalf("mesh-config asking new-form bus state: %+v", v) } if !slices.Equal(judge.Foundation, []string{"nats", "mesh-grants", "mesh-delivery-indexer", "mesh-delivery-assigner", "node-tools", - "mesh-delivery-builder", "mesh-delivery-checker", "mesh-config", "mesh-vault"}) { - t.Fatalf("the foundation is %v, want exactly the nine of ADR 0619", judge.Foundation) + "mesh-delivery-builder", "mesh-delivery-checker", "mesh-config", "mesh-vault", "mesh-delivery-pipeline"}) { + t.Fatalf("the foundation is %v, want exactly the nine of ADR 0619 and the pipeline runner (ADR 0666 §4)", judge.Foundation) } late := hold("late", `{"module":"late","version":"1","logs":[{"name":"l"}]}`) grants := hold("mesh-grants", `{"module":"mesh-grants","version":"2","logs":[{"name":"l"},{"name":"m"}]}`) diff --git a/modules/mesh-delivery-indexer/judge/oldsections.go b/modules/mesh-delivery-indexer/judge/oldsections.go index 91a2446d..a6da40ea 100644 --- a/modules/mesh-delivery-indexer/judge/oldsections.go +++ b/modules/mesh-delivery-indexer/judge/oldsections.go @@ -16,12 +16,13 @@ import ( // OldSections are the sections of a manifest that keep bus state in the old forms. var OldSections = []string{"state", "logs", "streams", "objects"} -// Foundation are the modules that keep using the old sections for good: exactly the nine parts the bus module's own -// composition, assignments, build or delivery needs before it can answer (novox/hq ADR 0619 §1, §3): the bus module, the -// grants part, the indexer, the assigner, the tool runner, the builder, the checker, mesh-config and the vault. Their new -// state goes on their old sections too, so a foundation manifest asking bus state in the new form is refused. +// Foundation are the modules that keep using the old sections for good: exactly the ten parts the bus module's own +// composition, assignments, build or delivery needs before it can answer (novox/hq ADR 0619 §1, §3, as ADR 0666 §4 changes +// them): the bus module, the grants part, the indexer, the assigner, the tool runner, the builder, the checker, mesh-config, +// the vault and the pipeline runner, which runs the bus module's own pipeline. Their new state goes on their old sections +// too, so a foundation manifest asking bus state in the new form is refused. var Foundation = []string{"nats", "mesh-grants", "mesh-delivery-indexer", "mesh-delivery-assigner", "node-tools", - "mesh-delivery-builder", "mesh-delivery-checker", "mesh-config", "mesh-vault"} + "mesh-delivery-builder", "mesh-delivery-checker", "mesh-config", "mesh-vault", "mesh-delivery-pipeline"} // ViewOwners are the two owners whose state every reader reads through their first views and read tools, never by a // `reads` entry (novox/hq ADR 0615 §1): CountOwnerReads counts what is left of those entries. From 6bbbceb55f8992e652fbadda8ba8bd7381c4e4e6 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 17:18:47 +0100 Subject: [PATCH 2/9] A-D29: a template in the runner's manifest is refused, as in every part on the validator's list (issue 1966) Design 47 *A module's registration* (b229f5c4 lines 480-481): a unit test of the manifest validator refuses a template in the manifest of every part on its list, the runner's included. The row of TestTheIndexerJudgesEveryManifestOnce now judges mesh-delivery-pipeline beside mesh-config; it fails with the list as it stood before 0ad243c1. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../internal/index/verdicts_test.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go index f8b1ab7e..c1d529ac 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go @@ -359,12 +359,16 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { } } // DLV-J1 (ADR 0619 §1, §3): a foundation part asking bus state in the new form is refused; the nine and the pipeline runner are the foundation. - cfg := hold("mesh-config", `{"module":"mesh-config","version":"1","requires":[{"name":"cache","storage":"key-value","template":"state"}]}`) - if _, err := ix.JudgeAll(ctx, map[string]bool{cfg: true}); err != nil { - t.Fatal(err) - } - if v := verdict(cfg); !meshcatalogclient.VerdictRefuses(v) || !strings.Contains(strings.Join(v.Problems, " "), "part of the foundation") { - t.Fatalf("mesh-config asking new-form bus state: %+v", v) + // Design 47 *A module's registration* (b229f5c4 lines 480-481): a template in the manifest of every part on the list is + // refused, the runner's included. + for _, part := range []string{"mesh-config", "mesh-delivery-pipeline"} { + d := hold(part, `{"module":"`+part+`","version":"1","requires":[{"name":"cache","storage":"key-value","template":"state"}]}`) + if _, err := ix.JudgeAll(ctx, map[string]bool{d: true}); err != nil { + t.Fatal(err) + } + if v := verdict(d); !meshcatalogclient.VerdictRefuses(v) || !strings.Contains(strings.Join(v.Problems, " "), "part of the foundation") { + t.Fatalf("%s asking new-form bus state: %+v", part, v) + } } if !slices.Equal(judge.Foundation, []string{"nats", "mesh-grants", "mesh-delivery-indexer", "mesh-delivery-assigner", "node-tools", "mesh-delivery-builder", "mesh-delivery-checker", "mesh-config", "mesh-vault", "mesh-delivery-pipeline"}) { From 6b85c9045e8fe38dc15873cb4e9dfa7098132829 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 11:26:51 +0100 Subject: [PATCH 3/9] IX20: the validator's tests hold a step's status out of a manifest and read the build seats' new accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manifest naming a status is refused as an unknown field (strict decoding); the two seat-table tests read the seat definitions as contracts now answer them. Builds: design 47 G §6, §21, §23, §28, §101, §95 test Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- modules/mesh-delivery-indexer/judge/judge_test.go | 3 +++ modules/mesh-delivery-indexer/judge/rules_test.go | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/mesh-delivery-indexer/judge/judge_test.go b/modules/mesh-delivery-indexer/judge/judge_test.go index 0eaf20e8..1acdc24e 100644 --- a/modules/mesh-delivery-indexer/judge/judge_test.go +++ b/modules/mesh-delivery-indexer/judge/judge_test.go @@ -27,6 +27,9 @@ func TestTheJudge(t *testing.T) { want: map[string]string{"a": `secret-`}}, {name: "an unknown field", raws: map[string]string{"a": `{"module":"a","version":"1","a-field-from-later":1}`}, want: map[string]string{"a": `"a-field-from-later"`}}, + // Design 47 G §23, §28: a step's status is kept in the registration and never in a manifest. + {name: "a manifest naming a step's status", raws: map[string]string{"a": `{"module":"a","version":"1","policy":{"lint":"bypass"}}`}, + want: map[string]string{"a": `"policy"`}}, {name: "a manifest under another name", raws: map[string]string{"b": `{"module":"a","version":"1"}`}, want: map[string]string{"b": `of module "a", not "b"`}}, {name: "a seat declared twice", diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 113c1610..58f17e35 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -3918,7 +3918,7 @@ func caseNodeBuildAgentIsRetiredForTheBuildSeats(t *testing.T) { } for _, name := range []string{"node-build-go", "node-build-node", "node-build-image"} { s, ok := catalogue.SeatNamed(name) - if !ok || s.Scope != catalogue.ScopeNode || !s.PulledByKind || strings.Join(s.Accepts, ",") != "delivery,check,recheck" { + if !ok || s.Scope != catalogue.ScopeNode || !s.PulledByKind || !slices.Contains(s.Accepts, "delivery") || !slices.Contains(s.Accepts, "check") || !slices.Contains(s.Accepts, "step") || !slices.Contains(s.Accepts, "recheck") { t.Fatalf("%s = %+v", name, s) } if !s.Yields { @@ -4683,7 +4683,7 @@ func caseTheMeshsOwnSeatsAreStillClaimable(t *testing.T) { // It delivers the bus, so its holder provides the bus — the rule this check now enforces. m := catalogue.Manifest{Module: "nats", Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh, Credential: &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}}}, - Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} + Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Serves: []string{"snapshot-kept", "restore-kept"}}}} if got := problemsFor(t, catalogue.Shelf{"nats": m}); got != "" { t.Fatalf("a mesh seat was refused by the derived check: %s", got) } From eca4320d1c95de90c3a3ad3b9ca8bd7790701161 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 11:31:09 +0100 Subject: [PATCH 4/9] CT1: the runner's step outcome not asked, a pipeline's end count, and the step log kept 100 days; regenerated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An approval step after a failed required step ends not asked, naming that step (the fixture of a failed pipeline said waiting before); Pipeline and PipelineEnded carry end-count, one higher at each end again after a passing rerun. The fixture rows fail against the contract before (end-count dropped on read). Builds: design 47 G §24, §325 (not asked), §29, §85, §325 (the end count), §85 (step-log, 100 days). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- contracts/go/seats/meshpipelinerunner/contract.go | 13 +++++++++---- .../go/seats/meshpipelinerunner/contract_test.go | 6 +++--- .../seats/mesh-pipeline-runner/fixtures/events.json | 8 +++++--- .../seats/mesh-pipeline-runner/fixtures/states.json | 1 + contracts/seats/mesh-pipeline-runner/seat.json | 11 ++++++++++- contracts/ts/seats/meshpipelinerunner/index.d.ts | 6 +++++- 6 files changed, 33 insertions(+), 12 deletions(-) diff --git a/contracts/go/seats/meshpipelinerunner/contract.go b/contracts/go/seats/meshpipelinerunner/contract.go index 9a5d4c11..39817255 100644 --- a/contracts/go/seats/meshpipelinerunner/contract.go +++ b/contracts/go/seats/meshpipelinerunner/contract.go @@ -418,8 +418,10 @@ type Pipeline struct { // the watched branch, or the branch of a run on request Branch string `json:"branch"` // the commit the module's entry was last changed at - Commit string `json:"commit"` - Ended *time.Time `json:"ended,omitempty"` + Commit string `json:"commit"` + // how often the pipeline has ended: 1 at its first end, one higher at each end again after a passing rerun, so the assigner takes a later end of the same source revision (design 47 G sections 29, 325) + EndCount int64 `json:"end-count,omitempty"` + Ended *time.Time `json:"ended,omitempty"` // the module's build-input fingerprint at the commit Fp string `json:"fp,omitempty"` // the gathered group, when the pipeline is its @@ -463,8 +465,10 @@ type PipelineEnded struct { // the watched branch, or the branch of a run on request Branch string `json:"branch"` // the commit the module's entry was last changed at - Commit string `json:"commit"` - Ended *time.Time `json:"ended,omitempty"` + Commit string `json:"commit"` + // how often the pipeline has ended: 1 at its first end, one higher at each end again after a passing rerun, so the assigner takes a later end of the same source revision (design 47 G sections 29, 325) + EndCount int64 `json:"end-count,omitempty"` + Ended *time.Time `json:"ended,omitempty"` // the module's build-input fingerprint at the commit Fp string `json:"fp,omitempty"` // the gathered group, when the pipeline is its @@ -630,6 +634,7 @@ const ( StepResultOutcomeWaitingForABuildNode = "waiting for a build node" StepResultOutcomeDidNotBuild = "did not build" StepResultOutcomeAccepted = "accepted" + StepResultOutcomeNotAsked = "not asked" StepResultOutcomeDeclined = "declined" StepResultOutcomeSuperseded = "superseded" StepResultOutcomeCancelled = "cancelled" diff --git a/contracts/go/seats/meshpipelinerunner/contract_test.go b/contracts/go/seats/meshpipelinerunner/contract_test.go index 44f3768e..ded5552b 100644 --- a/contracts/go/seats/meshpipelinerunner/contract_test.go +++ b/contracts/go/seats/meshpipelinerunner/contract_test.go @@ -19,9 +19,9 @@ func TestFixtures(t *testing.T) { }{ {name: "a pipeline started with the steps its policy gives", subject: "mesh.seat.mesh-pipeline-runner.event.pipeline-started.nats.main.0123456789abcdef0123456789abcdef01234567", given: "{\"at\":\"2026-11-10T10:00:00Z\",\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"steps\":[{\"artifact\":\"nats:program\",\"status\":\"required\",\"step\":\"lint\"},{\"status\":\"bypass\",\"step\":\"review\"}],\"v\":1}", wire: "{\"at\":\"2026-11-10T10:00:00Z\",\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"steps\":[{\"artifact\":\"nats:program\",\"status\":\"required\",\"step\":\"lint\"},{\"status\":\"bypass\",\"step\":\"review\"}],\"v\":1}", decode: func(b []byte) (any, error) { var v PipelineStartedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-pipeline-runner\\.event\\.pipeline-started\\.[^.*> ]+\\.[^.*> ]+\\.[^.*> ]+$")}, - {name: "a pipeline ended passed, an optional step failed, over budget and widening named", subject: "mesh.seat.mesh-pipeline-runner.event.pipeline-ended.nats.main.0123456789abcdef0123456789abcdef01234567", given: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"ended\":\"2026-11-10T10:20:00Z\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"passed\",\"over-budget\":true,\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"started\":\"2026-11-10T10:00:00Z\",\"steps\":[{\"artifact\":\"nats:program\",\"ask\":\"m2kx9a1b3c4d5e\",\"fingerprint\":\"fp-0123\",\"node\":\"ace\",\"outcome\":\"passed\",\"seat\":\"node-build-go\",\"status\":\"required\",\"step\":\"lint\"},{\"artifact\":\"nats:program\",\"carried-from\":\"main.11.nats\",\"fingerprint\":\"fp-0123\",\"outcome\":\"carried\",\"status\":\"required\",\"step\":\"unit\",\"tests\":{\"unit\":42}},{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"node\":\"g14\",\"outcome\":\"failed\",\"seat\":\"node-build-go\",\"status\":\"optional\",\"step\":\"integration\",\"tests\":{\"integration\":3},\"why\":\"TestTheBusWhole failed\"},{\"outcome\":\"passed\",\"seat\":\"mesh-wants\",\"status\":\"required\",\"step\":\"composition\"},{\"outcome\":\"bypassed\",\"status\":\"bypass\",\"step\":\"review\"},{\"outcome\":\"passed\",\"status\":\"required\",\"step\":\"approval\"}],\"tests\":{\"integration\":3,\"unit\":42},\"v\":1,\"widens\":[{\"name\":\"mesh-build-go:program\",\"said\":\"its test runner\",\"what\":\"serving part\"}]}", wire: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"ended\":\"2026-11-10T10:20:00Z\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"passed\",\"over-budget\":true,\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"started\":\"2026-11-10T10:00:00Z\",\"steps\":[{\"artifact\":\"nats:program\",\"ask\":\"m2kx9a1b3c4d5e\",\"fingerprint\":\"fp-0123\",\"node\":\"ace\",\"outcome\":\"passed\",\"seat\":\"node-build-go\",\"status\":\"required\",\"step\":\"lint\"},{\"artifact\":\"nats:program\",\"carried-from\":\"main.11.nats\",\"fingerprint\":\"fp-0123\",\"outcome\":\"carried\",\"status\":\"required\",\"step\":\"unit\",\"tests\":{\"unit\":42}},{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"node\":\"g14\",\"outcome\":\"failed\",\"seat\":\"node-build-go\",\"status\":\"optional\",\"step\":\"integration\",\"tests\":{\"integration\":3},\"why\":\"TestTheBusWhole failed\"},{\"outcome\":\"passed\",\"seat\":\"mesh-wants\",\"status\":\"required\",\"step\":\"composition\"},{\"outcome\":\"bypassed\",\"status\":\"bypass\",\"step\":\"review\"},{\"outcome\":\"passed\",\"status\":\"required\",\"step\":\"approval\"}],\"tests\":{\"integration\":3,\"unit\":42},\"v\":1,\"widens\":[{\"name\":\"mesh-build-go:program\",\"said\":\"its test runner\",\"what\":\"serving part\"}]}", + {name: "a pipeline ended passed, an optional step failed, over budget and widening named", subject: "mesh.seat.mesh-pipeline-runner.event.pipeline-ended.nats.main.0123456789abcdef0123456789abcdef01234567", given: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"end-count\":1,\"ended\":\"2026-11-10T10:20:00Z\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"passed\",\"over-budget\":true,\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"started\":\"2026-11-10T10:00:00Z\",\"steps\":[{\"artifact\":\"nats:program\",\"ask\":\"m2kx9a1b3c4d5e\",\"fingerprint\":\"fp-0123\",\"node\":\"ace\",\"outcome\":\"passed\",\"seat\":\"node-build-go\",\"status\":\"required\",\"step\":\"lint\"},{\"artifact\":\"nats:program\",\"carried-from\":\"main.11.nats\",\"fingerprint\":\"fp-0123\",\"outcome\":\"carried\",\"status\":\"required\",\"step\":\"unit\",\"tests\":{\"unit\":42}},{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"node\":\"g14\",\"outcome\":\"failed\",\"seat\":\"node-build-go\",\"status\":\"optional\",\"step\":\"integration\",\"tests\":{\"integration\":3},\"why\":\"TestTheBusWhole failed\"},{\"outcome\":\"passed\",\"seat\":\"mesh-wants\",\"status\":\"required\",\"step\":\"composition\"},{\"outcome\":\"bypassed\",\"status\":\"bypass\",\"step\":\"review\"},{\"outcome\":\"passed\",\"status\":\"required\",\"step\":\"approval\"}],\"tests\":{\"integration\":3,\"unit\":42},\"v\":1,\"widens\":[{\"name\":\"mesh-build-go:program\",\"said\":\"its test runner\",\"what\":\"serving part\"}]}", wire: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"end-count\":1,\"ended\":\"2026-11-10T10:20:00Z\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"passed\",\"over-budget\":true,\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"started\":\"2026-11-10T10:00:00Z\",\"steps\":[{\"artifact\":\"nats:program\",\"ask\":\"m2kx9a1b3c4d5e\",\"fingerprint\":\"fp-0123\",\"node\":\"ace\",\"outcome\":\"passed\",\"seat\":\"node-build-go\",\"status\":\"required\",\"step\":\"lint\"},{\"artifact\":\"nats:program\",\"carried-from\":\"main.11.nats\",\"fingerprint\":\"fp-0123\",\"outcome\":\"carried\",\"status\":\"required\",\"step\":\"unit\",\"tests\":{\"unit\":42}},{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"node\":\"g14\",\"outcome\":\"failed\",\"seat\":\"node-build-go\",\"status\":\"optional\",\"step\":\"integration\",\"tests\":{\"integration\":3},\"why\":\"TestTheBusWhole failed\"},{\"outcome\":\"passed\",\"seat\":\"mesh-wants\",\"status\":\"required\",\"step\":\"composition\"},{\"outcome\":\"bypassed\",\"status\":\"bypass\",\"step\":\"review\"},{\"outcome\":\"passed\",\"status\":\"required\",\"step\":\"approval\"}],\"tests\":{\"integration\":3,\"unit\":42},\"v\":1,\"widens\":[{\"name\":\"mesh-build-go:program\",\"said\":\"its test runner\",\"what\":\"serving part\"}]}", decode: func(b []byte) (any, error) { var v PipelineEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-pipeline-runner\\.event\\.pipeline-ended\\.[^.*> ]+\\.[^.*> ]+\\.[^.*> ]+$")}, - {name: "a pipeline ended failed, naming the step that held it", subject: "mesh.seat.mesh-pipeline-runner.event.pipeline-ended.nats.main.0123456789abcdef0123456789abcdef01234567", given: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"failed\",\"over-budget\":true,\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"steps\":[{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"outcome\":\"failed\",\"status\":\"required\",\"step\":\"unit\"},{\"on\":\"the operator\",\"outcome\":\"waiting\",\"status\":\"required\",\"step\":\"approval\"}],\"v\":1,\"why\":\"unit of nats:program failed\"}", wire: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"failed\",\"over-budget\":true,\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"steps\":[{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"outcome\":\"failed\",\"status\":\"required\",\"step\":\"unit\"},{\"on\":\"the operator\",\"outcome\":\"waiting\",\"status\":\"required\",\"step\":\"approval\"}],\"v\":1,\"why\":\"unit of nats:program failed\"}", + {name: "a pipeline ended failed, naming the step that held it, its approval not asked", subject: "mesh.seat.mesh-pipeline-runner.event.pipeline-ended.nats.main.0123456789abcdef0123456789abcdef01234567", given: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"end-count\":1,\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"failed\",\"over-budget\":true,\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"steps\":[{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"outcome\":\"failed\",\"status\":\"required\",\"step\":\"unit\"},{\"outcome\":\"not asked\",\"status\":\"required\",\"step\":\"approval\",\"why\":\"unit of nats:program failed\"}],\"v\":1,\"why\":\"unit of nats:program failed\"}", wire: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"end-count\":1,\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"failed\",\"over-budget\":true,\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"steps\":[{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"outcome\":\"failed\",\"status\":\"required\",\"step\":\"unit\"},{\"outcome\":\"not asked\",\"status\":\"required\",\"step\":\"approval\",\"why\":\"unit of nats:program failed\"}],\"v\":1,\"why\":\"unit of nats:program failed\"}", decode: func(b []byte) (any, error) { var v PipelineEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-pipeline-runner\\.event\\.pipeline-ended\\.[^.*> ]+\\.[^.*> ]+\\.[^.*> ]+$")}, {name: "a group's one pipeline ended on a watched branch", subject: "mesh.seat.mesh-pipeline-runner.event.group-ended.main.registry-1966", given: "{\"branch\":\"main\",\"ended\":\"2026-11-10T10:20:00Z\",\"group\":\"registry-1966\",\"outcome\":\"passed\",\"pipelines\":[{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fp\":\"fp-0123\",\"group\":\"registry-1966\",\"module\":\"nats\",\"outcome\":\"passed\",\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"steps\":[{\"artifact\":\"nats:program\",\"ask\":\"m2kx9a1b3c4d5e\",\"fingerprint\":\"fp-0123\",\"node\":\"ace\",\"outcome\":\"passed\",\"seat\":\"node-build-go\",\"status\":\"required\",\"step\":\"lint\"}],\"v\":1}],\"revision\":12,\"source\":3589,\"v\":1}", wire: "{\"branch\":\"main\",\"ended\":\"2026-11-10T10:20:00Z\",\"group\":\"registry-1966\",\"outcome\":\"passed\",\"pipelines\":[{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fp\":\"fp-0123\",\"group\":\"registry-1966\",\"module\":\"nats\",\"outcome\":\"passed\",\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"steps\":[{\"artifact\":\"nats:program\",\"ask\":\"m2kx9a1b3c4d5e\",\"fingerprint\":\"fp-0123\",\"node\":\"ace\",\"outcome\":\"passed\",\"seat\":\"node-build-go\",\"status\":\"required\",\"step\":\"lint\"}],\"v\":1}],\"revision\":12,\"source\":3589,\"v\":1}", decode: func(b []byte) (any, error) { var v GroupEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-pipeline-runner\\.event\\.group-ended\\.[^.*> ]+\\.[^.*> ]+$")}, @@ -37,7 +37,7 @@ func TestFixtures(t *testing.T) { decode: func(b []byte) (any, error) { var v WaitHeldEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-pipeline-runner\\.event\\.wait-held\\.[^.*> ]+\\.[^.*> ]+\\.[^.*> ]+$")}, {name: "the wait ended answered", subject: "mesh.seat.mesh-pipeline-runner.event.wait-ended.nats.main.0123456789abcdef0123456789abcdef01234567", given: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fp\":\"fp-0123\",\"how\":\"answered\",\"module\":\"nats\",\"on\":\"person\",\"pipeline\":\"main.12.nats\",\"revision\":12,\"since\":\"2026-11-10T10:10:00Z\",\"source\":3589,\"step\":\"approval\",\"v\":1,\"who\":\"the operator\"}", wire: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fp\":\"fp-0123\",\"how\":\"answered\",\"module\":\"nats\",\"on\":\"person\",\"pipeline\":\"main.12.nats\",\"revision\":12,\"since\":\"2026-11-10T10:10:00Z\",\"source\":3589,\"step\":\"approval\",\"v\":1,\"who\":\"the operator\"}", decode: func(b []byte) (any, error) { var v WaitEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-pipeline-runner\\.event\\.wait-ended\\.[^.*> ]+\\.[^.*> ]+\\.[^.*> ]+$")}, - {name: "a pipeline kept", subject: "", given: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"ended\":\"2026-11-10T10:20:00Z\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"passed\",\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"started\":\"2026-11-10T10:00:00Z\",\"steps\":[{\"artifact\":\"nats:program\",\"ask\":\"m2kx9a1b3c4d5e\",\"fingerprint\":\"fp-0123\",\"node\":\"ace\",\"outcome\":\"passed\",\"seat\":\"node-build-go\",\"status\":\"required\",\"step\":\"lint\"},{\"artifact\":\"nats:program\",\"carried-from\":\"main.11.nats\",\"fingerprint\":\"fp-0123\",\"outcome\":\"carried\",\"status\":\"required\",\"step\":\"unit\",\"tests\":{\"unit\":42}},{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"node\":\"g14\",\"outcome\":\"failed\",\"seat\":\"node-build-go\",\"status\":\"optional\",\"step\":\"integration\",\"tests\":{\"integration\":3},\"why\":\"TestTheBusWhole failed\"},{\"outcome\":\"passed\",\"seat\":\"mesh-wants\",\"status\":\"required\",\"step\":\"composition\"},{\"outcome\":\"bypassed\",\"status\":\"bypass\",\"step\":\"review\"},{\"outcome\":\"passed\",\"status\":\"required\",\"step\":\"approval\"}],\"v\":1}", wire: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"ended\":\"2026-11-10T10:20:00Z\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"passed\",\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"started\":\"2026-11-10T10:00:00Z\",\"steps\":[{\"artifact\":\"nats:program\",\"ask\":\"m2kx9a1b3c4d5e\",\"fingerprint\":\"fp-0123\",\"node\":\"ace\",\"outcome\":\"passed\",\"seat\":\"node-build-go\",\"status\":\"required\",\"step\":\"lint\"},{\"artifact\":\"nats:program\",\"carried-from\":\"main.11.nats\",\"fingerprint\":\"fp-0123\",\"outcome\":\"carried\",\"status\":\"required\",\"step\":\"unit\",\"tests\":{\"unit\":42}},{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"node\":\"g14\",\"outcome\":\"failed\",\"seat\":\"node-build-go\",\"status\":\"optional\",\"step\":\"integration\",\"tests\":{\"integration\":3},\"why\":\"TestTheBusWhole failed\"},{\"outcome\":\"passed\",\"seat\":\"mesh-wants\",\"status\":\"required\",\"step\":\"composition\"},{\"outcome\":\"bypassed\",\"status\":\"bypass\",\"step\":\"review\"},{\"outcome\":\"passed\",\"status\":\"required\",\"step\":\"approval\"}],\"v\":1}", + {name: "a pipeline kept", subject: "", given: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"end-count\":1,\"ended\":\"2026-11-10T10:20:00Z\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"passed\",\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"started\":\"2026-11-10T10:00:00Z\",\"steps\":[{\"artifact\":\"nats:program\",\"ask\":\"m2kx9a1b3c4d5e\",\"fingerprint\":\"fp-0123\",\"node\":\"ace\",\"outcome\":\"passed\",\"seat\":\"node-build-go\",\"status\":\"required\",\"step\":\"lint\"},{\"artifact\":\"nats:program\",\"carried-from\":\"main.11.nats\",\"fingerprint\":\"fp-0123\",\"outcome\":\"carried\",\"status\":\"required\",\"step\":\"unit\",\"tests\":{\"unit\":42}},{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"node\":\"g14\",\"outcome\":\"failed\",\"seat\":\"node-build-go\",\"status\":\"optional\",\"step\":\"integration\",\"tests\":{\"integration\":3},\"why\":\"TestTheBusWhole failed\"},{\"outcome\":\"passed\",\"seat\":\"mesh-wants\",\"status\":\"required\",\"step\":\"composition\"},{\"outcome\":\"bypassed\",\"status\":\"bypass\",\"step\":\"review\"},{\"outcome\":\"passed\",\"status\":\"required\",\"step\":\"approval\"}],\"v\":1}", wire: "{\"branch\":\"main\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"end-count\":1,\"ended\":\"2026-11-10T10:20:00Z\",\"fp\":\"fp-0123\",\"module\":\"nats\",\"outcome\":\"passed\",\"pipeline\":\"main.12.nats\",\"revision\":12,\"source\":3589,\"started\":\"2026-11-10T10:00:00Z\",\"steps\":[{\"artifact\":\"nats:program\",\"ask\":\"m2kx9a1b3c4d5e\",\"fingerprint\":\"fp-0123\",\"node\":\"ace\",\"outcome\":\"passed\",\"seat\":\"node-build-go\",\"status\":\"required\",\"step\":\"lint\"},{\"artifact\":\"nats:program\",\"carried-from\":\"main.11.nats\",\"fingerprint\":\"fp-0123\",\"outcome\":\"carried\",\"status\":\"required\",\"step\":\"unit\",\"tests\":{\"unit\":42}},{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"node\":\"g14\",\"outcome\":\"failed\",\"seat\":\"node-build-go\",\"status\":\"optional\",\"step\":\"integration\",\"tests\":{\"integration\":3},\"why\":\"TestTheBusWhole failed\"},{\"outcome\":\"passed\",\"seat\":\"mesh-wants\",\"status\":\"required\",\"step\":\"composition\"},{\"outcome\":\"bypassed\",\"status\":\"bypass\",\"step\":\"review\"},{\"outcome\":\"passed\",\"status\":\"required\",\"step\":\"approval\"}],\"v\":1}", decode: func(b []byte) (any, error) { var v PipelinesByBranchRevisionModuleValue err := json.Unmarshal(b, &v) diff --git a/contracts/seats/mesh-pipeline-runner/fixtures/events.json b/contracts/seats/mesh-pipeline-runner/fixtures/events.json index cdd9d6c9..3eb22465 100644 --- a/contracts/seats/mesh-pipeline-runner/fixtures/events.json +++ b/contracts/seats/mesh-pipeline-runner/fixtures/events.json @@ -36,6 +36,7 @@ "branch": "main", "revision": 12, "source": 3589, + "end-count": 1, "commit": "0123456789abcdef0123456789abcdef01234567", "fp": "fp-0123", "v": 1, @@ -109,7 +110,7 @@ } }, { - "name": "a pipeline ended failed, naming the step that held it", + "name": "a pipeline ended failed, naming the step that held it, its approval not asked", "of": "event:pipeline-ended", "subject": "mesh.seat.mesh-pipeline-runner.event.pipeline-ended.nats.main.0123456789abcdef0123456789abcdef01234567", "given": { @@ -118,6 +119,7 @@ "branch": "main", "revision": 12, "source": 3589, + "end-count": 1, "commit": "0123456789abcdef0123456789abcdef01234567", "fp": "fp-0123", "v": 1, @@ -134,8 +136,8 @@ { "step": "approval", "status": "required", - "outcome": "waiting", - "on": "the operator" + "outcome": "not asked", + "why": "unit of nats:program failed" } ], "over-budget": true diff --git a/contracts/seats/mesh-pipeline-runner/fixtures/states.json b/contracts/seats/mesh-pipeline-runner/fixtures/states.json index 318e10d5..f5873247 100644 --- a/contracts/seats/mesh-pipeline-runner/fixtures/states.json +++ b/contracts/seats/mesh-pipeline-runner/fixtures/states.json @@ -8,6 +8,7 @@ "branch": "main", "revision": 12, "source": 3589, + "end-count": 1, "commit": "0123456789abcdef0123456789abcdef01234567", "fp": "fp-0123", "v": 1, diff --git a/contracts/seats/mesh-pipeline-runner/seat.json b/contracts/seats/mesh-pipeline-runner/seat.json index 91df6fff..5d972333 100644 --- a/contracts/seats/mesh-pipeline-runner/seat.json +++ b/contracts/seats/mesh-pipeline-runner/seat.json @@ -678,7 +678,7 @@ { "name": "step-log", "template": "log", - "max-age-days": 90, + "max-age-days": 100, "description": "one entry per step's start and end, its output by name; kept on the runner's old streams section", "key-forms": [ { @@ -856,6 +856,7 @@ "waiting for a build node", "did not build", "accepted", + "not asked", "declined", "superseded", "cancelled" @@ -974,6 +975,10 @@ "type": "integer", "description": "the source revision of the watched branch; the pipeline is keyed by it (ADR 0666 section 9)" }, + "end-count": { + "type": "integer", + "description": "how often the pipeline has ended: 1 at its first end, one higher at each end again after a passing rerun, so the assigner takes a later end of the same source revision (design 47 G sections 29, 325)" + }, "commit": { "type": "string", "description": "the commit the module's entry was last changed at" @@ -1072,6 +1077,10 @@ "type": "integer", "description": "the source revision of the watched branch; the pipeline is keyed by it (ADR 0666 section 9)" }, + "end-count": { + "type": "integer", + "description": "how often the pipeline has ended: 1 at its first end, one higher at each end again after a passing rerun, so the assigner takes a later end of the same source revision (design 47 G sections 29, 325)" + }, "commit": { "type": "string", "description": "the commit the module's entry was last changed at" diff --git a/contracts/ts/seats/meshpipelinerunner/index.d.ts b/contracts/ts/seats/meshpipelinerunner/index.d.ts index e824d349..cf1d8263 100644 --- a/contracts/ts/seats/meshpipelinerunner/index.d.ts +++ b/contracts/ts/seats/meshpipelinerunner/index.d.ts @@ -182,6 +182,8 @@ export interface Pipeline { "branch": string; /** the commit the module's entry was last changed at */ "commit": string; + /** how often the pipeline has ended: 1 at its first end, one higher at each end again after a passing rerun, so the assigner takes a later end of the same source revision (design 47 G sections 29, 325) */ + "end-count"?: number; "ended"?: string; /** the module's build-input fingerprint at the commit */ "fp"?: string; @@ -217,6 +219,8 @@ export interface PipelineEnded { "branch": string; /** the commit the module's entry was last changed at */ "commit": string; + /** how often the pipeline has ended: 1 at its first end, one higher at each end again after a passing rerun, so the assigner takes a later end of the same source revision (design 47 G sections 29, 325) */ + "end-count"?: number; "ended"?: string; /** the module's build-input fingerprint at the commit */ "fp"?: string; @@ -326,7 +330,7 @@ export interface StepResult { /** for a waiting step, who or what it waits on: a person, a build node, the revision view */ "on"?: string; /** never passed for a step that ran nothing (design 47 *Pipeline steps and the policy*) */ - "outcome": "running" | "waiting" | "passed" | "failed" | "nothing to run" | "bypassed" | "carried" | "could not run" | "waiting for a build node" | "did not build" | "accepted" | "declined" | "superseded" | "cancelled"; + "outcome": "running" | "waiting" | "passed" | "failed" | "nothing to run" | "bypassed" | "carried" | "could not run" | "waiting for a build node" | "did not build" | "accepted" | "not asked" | "declined" | "superseded" | "cancelled"; /** the step's output, by its name in the runner's objects step-outputs */ "output"?: string; /** the step was asked again by the runner's rerun */ From 540b88a94679f3c04a624e7962bf10bb37842d1c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 11:32:05 +0100 Subject: [PATCH 5/9] IX21: the validator reads a manifest's version MAJOR.MINOR and the finding stream and ask of a findings state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refusal of a status in a manifest (IX20) relies on strict decoding: a manifest field naming a status is an unknown field and is refused. The `*` emitter of consumes (design 47 G §386) is held for the design writer's answer on model-usage's `*.usage.*`. Builds: design 47 G §89, §368, §387 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-delivery-indexer/integration_test.go | 14 +- .../packages_integration_test.go | 2 +- .../internal/index/control_records_test.go | 24 +- .../internal/index/holds_test.go | 6 +- .../internal/index/index_test.go | 4 +- .../internal/index/inputs_test.go | 2 +- .../internal/index/modules_test.go | 4 +- .../internal/index/named_test.go | 2 +- .../internal/index/pending_test.go | 2 +- .../internal/index/registered_test.go | 10 +- .../internal/index/stale_none2_test.go | 2 +- .../internal/index/toolchains_test.go | 4 +- .../internal/index/verdicts_test.go | 72 +-- .../judge/busstate_test.go | 18 +- .../judge/g_words_test.go | 33 ++ modules/mesh-delivery-indexer/judge/gwords.go | 54 +++ modules/mesh-delivery-indexer/judge/judge.go | 3 + .../mesh-delivery-indexer/judge/judge_test.go | 64 ++- .../judge/objects_test.go | 6 +- modules/mesh-delivery-indexer/judge/rules.go | 2 +- .../mesh-delivery-indexer/judge/rules_test.go | 420 +++++++++--------- 21 files changed, 427 insertions(+), 321 deletions(-) create mode 100644 modules/mesh-delivery-indexer/judge/g_words_test.go create mode 100644 modules/mesh-delivery-indexer/judge/gwords.go diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go index ce7132fd..048f59d7 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go @@ -206,7 +206,7 @@ func TestIndexerWhole(t *testing.T) { // 1238/AC2, AC10; 1585/T14, T15 m1 := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: h1, Merge: "1111111111111111111111111111111111111111", Root: "r0", Trees: map[string]string{"modules/" + ix: "tree-indexer-2", "modules/a": "tree-a-2"}, - Manifests: map[string]string{"modules/" + ix: string(manifest), "modules/a": `{"module":"a","version":"1"}`}, + Manifests: map[string]string{"modules/" + ix: string(manifest), "modules/a": `{"module":"a","version":"1.0"}`}, Locks: map[string]map[string]string{"modules/" + ix: {"go.sum": "3b5028f0c1e9bde04dd766abd63eb5931bc516bae695cf46f00abcd8510b0381"}}} merged := func(f forgemsg.Forge) uint64 { t.Helper() @@ -305,10 +305,10 @@ func TestIndexerWhole(t *testing.T) { // 1238/AC2, AC10; 1585/T14, T15 {Repository: "novox/mesh-controller", Number: 41, Head: "4141414141414141414141414141414141414141"}} g1 := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-catalog", Branch: "feat/g", Number: 40, Head: members[0].Head, Merge: "4000000000000000000000000000000000000000", Root: "r0", Trees: map[string]string{"modules/c": "tree-c-2"}, - Manifests: map[string]string{"modules/c": `{"module":"c","version":"1"}`}, Group: "feat/g", Members: members} + Manifests: map[string]string{"modules/c": `{"module":"c","version":"1.0"}`}, Group: "feat/g", Members: members} g2 := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-controller", Branch: "feat/g", Number: 41, Head: members[1].Head, Merge: "4100000000000000000000000000000000000000", Root: "r0", Trees: map[string]string{"modules/k": "tree-k-1"}, - Manifests: map[string]string{"modules/k": `{"module":"k","version":"1"}`}, Group: "feat/g", Members: members} + Manifests: map[string]string{"modules/k": `{"module":"k","version":"1.0"}`}, Group: "feat/g", Members: members} g1Key, groupKey := takein.PendingKey(merged(g1)), takein.GroupTakeInKey("feat/g") if gathered, ok := said(takein.TakeInPending, groupKey); !ok || gathered.What != takein.TakeInGroup || len(gathered.Merges) != 1 { t.Fatalf("take-in.pending.%s = %+v (said %v) within 20 s, want the group with its first member", groupKey, gathered, ok) @@ -618,7 +618,7 @@ func TestIndexerWritesModulesAtAMerge(t *testing.T) { // 1239/AC9; I1, I3: the m const merge = "89abcdef0123456789abcdef0123456789abcdef" data, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567", Merge: merge, Root: "r0", Trees: map[string]string{"modules/a": "newtree"}, - Manifests: map[string]string{"modules/a": `{"module":"a","version":"1"}`}}) + Manifests: map[string]string{"modules/a": `{"module":"a","version":"1.0"}`}}) if err != nil { t.Fatal(err) } @@ -655,9 +655,9 @@ func TestIndexerKeepsManifestsAtAMerge(t *testing.T) { // 1444/AC4, 1449/AC4, 15 } const goSum = "3b5028f0c1e9bde04dd766abd63eb5931bc516bae695cf46f00abcd8510b0381" judged := map[string]string{ - "good": `{"module":"good","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/good","mode":"0700"}],"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`, - "bad": `{"module":"bad","version":"1","provides":[{"name":"amqp"}],"resources":[{"id":"d","type":"directory","path":"/srv/bad","mode":"0700"}],"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`, - "later": `{"module":"later","version":"1","a-field-from-later":1,"resources":[{"id":"d","type":"directory","path":"/srv/later","mode":"0700"}],"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`, + "good": `{"module":"good","version":"1.0","resources":[{"id":"d","type":"directory","path":"/srv/good","mode":"0700"}],"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`, + "bad": `{"module":"bad","version":"1.0","provides":[{"name":"amqp"}],"resources":[{"id":"d","type":"directory","path":"/srv/bad","mode":"0700"}],"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`, + "later": `{"module":"later","version":"1.0","a-field-from-later":1,"resources":[{"id":"d","type":"directory","path":"/srv/later","mode":"0700"}],"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`, } js, ctx, _, modules := startIndexer(t, func(ctx context.Context, builds buildLog) { builds.end(t, "novox/mesh-catalog", "avahi", "modules/avahi") diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go index 912af36f..40e3abd3 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go @@ -226,7 +226,7 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) if got, err := index.SeatDefinitionsOf(jsSource{ctx, seatDefs}, nil, ""); err != nil || func() string { raw, _ := json.Marshal(got); return string(raw) }() != `{"revision":0,"seats":[]}` { t.Errorf("seat-definitions of an indexer that kept none = %+v, %v", got, err) } - contracts := `{"module":"mesh-contracts","version":"1","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}` + contracts := `{"module":"mesh-contracts","version":"1.0","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}` merged("novox/mesh-contracts", 80, contractsMerge, forgemsg.Forge{Root: "dddddddddddddddddddddddddddddddddddddddd", Trees: map[string]string{"": "dddddddddddddddddddddddddddddddddddddddd"}, Manifests: map[string]string{"": contracts}}) entry := func(key string, ok func(index.SeatDefinitionEntry) bool) index.SeatDefinitionEntry { diff --git a/modules/mesh-delivery-indexer/internal/index/control_records_test.go b/modules/mesh-delivery-indexer/internal/index/control_records_test.go index f625affa..66f7948c 100644 --- a/modules/mesh-delivery-indexer/internal/index/control_records_test.go +++ b/modules/mesh-delivery-indexer/internal/index/control_records_test.go @@ -76,8 +76,8 @@ func TestTheControlRecords(t *testing.T) { } modules[name] = &views.Entry{Value: raw, Revision: 1} } - asks := `{"module":"cache","version":"1","requires":[{"name":"c","storage":"key-value","template":"state"}]}` - for digest, raw := range map[string]string{dAsk: asks, dNone: `{"module":"plain","version":"1"}`, dRefused: asks} { + asks := `{"module":"cache","version":"1.0","requires":[{"name":"c","storage":"key-value","template":"state"}]}` + for digest, raw := range map[string]string{dAsk: asks, dNone: `{"module":"plain","version":"1.0"}`, dRefused: asks} { put(manifests, digest, 1, meshcatalog.ManifestsByDigestValue{V: keys.Version, Module: "m", Manifest: raw}) } putVerdict(dAsk, 7, meshcatalog.VerdictsByDigestValue{Module: "cache", State: meshcatalogclient.VerdictAccepted, Judge: "j"}) @@ -127,7 +127,7 @@ func TestTheControlRecords(t *testing.T) { // raises generated-types-out-of-step from a trunk build end, cleared only by a clean one of the same module. t.Run("seat definitions", func(t *testing.T) { const repo, m1, m2, m3, head = "novox/mesh-contracts", "m1", "m2", "m3", "h1" - manifest := `{"module":"mesh-contracts","version":"1","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}` + manifest := `{"module":"mesh-contracts","version":"1.0","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}` git := `{"format":1,"seat":"git","scope":"mesh","grain":"seat-list","decision":"novox/hq ADR 0111","domain":"Core","delivers":"git"}` files := map[string]map[string]string{ m1: {"seats/git/seat.json": git, "seats/gone/seat.json": strings.Replace(git, `"git"`, `"gone"`, 1)}, @@ -162,7 +162,7 @@ func TestTheControlRecords(t *testing.T) { merged := func(merge string) { t.Helper() if err := ix.ApplySeatDefinitions(ctx, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repo, Number: 1, Merge: merge, - Manifests: map[string]string{meshcatalogclient.RootPath: manifest, "modules/other": `{"module":"other","version":"1"}`}}); err != nil { + Manifests: map[string]string{meshcatalogclient.RootPath: manifest, "modules/other": `{"module":"other","version":"1.0"}`}}); err != nil { t.Fatal(err) } } @@ -284,7 +284,7 @@ func TestTheControlRecords(t *testing.T) { // Ruling (lead, on A2i's finding 3): indexer_seat_definitions_again for a pull request reads the head's own manifest's // generated-from: a trunk that names none, a head that names it, is read as the contracts module the head is. ix.Modules, ix.Manifests = newMem(), newMem() - plain := `{"module":"mesh-contracts","version":"1"}` + plain := `{"module":"mesh-contracts","version":"1.0"}` kept, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "mesh-contracts", Manifest: plain}) mkey, _ := meshcatalogclient.ManifestsKey(meshcatalogclient.ManifestDigest([]byte(plain))) ix.Manifests.(*memStore).vals[mkey] = kept @@ -425,7 +425,7 @@ func TestTheControlRecords(t *testing.T) { } return nil, nil }} - sdk := `{"module":"mesh-sdk","version":"1","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"]},` + + sdk := `{"module":"mesh-sdk","version":"1.0","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"]},` + `{"name":"ts","kind":"package","language":"typescript","paths":["src","package.json"],"series":"0.3"}]}}` if err := ix.KeepPackageNames(ctx, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-sdk", Merge: "m1", Manifests: map[string]string{meshcatalogclient.RootPath: sdk}}); err != nil { @@ -498,11 +498,11 @@ func TestTheControlRecords(t *testing.T) { pkgs, imports []string pinned string }{ - "new": {"novox/mesh-catalog", `{"module":"new","version":"1","packages":{"git.novox.be/novox/mesh-sdk/go":"0.2","@novox/ui":"1.0"}}`, nil, nil, ""}, - "old": {"novox/mesh-catalog", `{"module":"old","version":"1"}`, []string{sdkGo}, nil, "v0.1.15"}, - "imports": {"novox/mesh-tools", `{"module":"imports","version":"1"}`, nil, []string{sdkGo + "/views"}, ""}, - "sibling": {"novox/mesh-sdk", `{"module":"sibling","version":"1"}`, []string{sdkGo}, nil, ""}, - "none": {"novox/mesh-catalog", `{"module":"none","version":"1"}`, nil, nil, ""}, + "new": {"novox/mesh-catalog", `{"module":"new","version":"1.0","packages":{"git.novox.be/novox/mesh-sdk/go":"0.2","@novox/ui":"1.0"}}`, nil, nil, ""}, + "old": {"novox/mesh-catalog", `{"module":"old","version":"1.0"}`, []string{sdkGo}, nil, "v0.1.15"}, + "imports": {"novox/mesh-tools", `{"module":"imports","version":"1.0"}`, nil, []string{sdkGo + "/views"}, ""}, + "sibling": {"novox/mesh-sdk", `{"module":"sibling","version":"1.0"}`, []string{sdkGo}, nil, ""}, + "none": {"novox/mesh-catalog", `{"module":"none","version":"1.0"}`, nil, nil, ""}, } { digest := fmt.Sprintf("%064x", len(name)*16+int(name[0])) putJSON(manifests, digest, meshcatalog.ManifestsByDigestValue{V: keys.Version, Module: name, Manifest: u.manifest}) @@ -569,7 +569,7 @@ func TestTheControlRecords(t *testing.T) { ix, _, _ := manifestsIndexer(t, rec(t, "build-1", "avahi", catalogueRepo, "modules/avahi", "main", "", "2026-10-19T09:00:00Z", aInputs)) cands := newMem() ix.ConfigCandidates, ix.Now = cands, func() time.Time { return at } - trunk := `{"module":"avahi","version":"1","config":{"port":{"type":"integer","default":5353,"why":"mDNS"}}}` + trunk := `{"module":"avahi","version":"1.0","config":{"port":{"type":"integer","default":5353,"why":"mDNS"}}}` if err := ix.Apply(ctx, withManifests(mergedMsg(catalogueRepo, map[string]string{"modules/avahi": "t1"}), map[string]string{"modules/avahi": trunk}, nil)); err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-indexer/internal/index/holds_test.go b/modules/mesh-delivery-indexer/internal/index/holds_test.go index 5466786f..eb30b90b 100644 --- a/modules/mesh-delivery-indexer/internal/index/holds_test.go +++ b/modules/mesh-delivery-indexer/internal/index/holds_test.go @@ -34,10 +34,10 @@ func (b fakeBounds) Disarm(_ context.Context, key string) error { delete(b, key) func storeHoldersNewManifestWaitsForEveryCountedEngine(t *testing.T) { ctx := context.Background() const dir, dockerDir = "modules/distribution", "modules/docker" - old := `{"module":"distribution","version":"1","listens":[{"name":"registry","port":5000,"from":"mesh"}]}` - next := `{"module":"distribution","version":"1","claims":[{"name":"container-image-registry","scope":"mesh"}],` + + old := `{"module":"distribution","version":"1.0","listens":[{"name":"registry","port":5000,"from":"mesh"}]}` + next := `{"module":"distribution","version":"1.0","claims":[{"name":"container-image-registry","scope":"mesh"}],` + `"listens":[{"name":"registry","port":5000,"from":"mesh"}]}` - docker := `{"module":"docker","version":"1","resources":[{"id":"daemon","type":"file","path":"/etc/docker/daemon.json","template":"files/daemon.json"}]}` + docker := `{"module":"docker","version":"1.0","resources":[{"id":"daemon","type":"file","path":"/etc/docker/daemon.json","template":"files/daemon.json"}]}` dockerNeeds, _ := catalogue.ModuleNeeds([]byte(docker), map[string]string{"files/daemon.json": `"${seat:container-image-registry:reach}"`}) if needs, _ := catalogue.ManifestNeeds([]byte(next)); !slices.Contains(needs, "form:seat-reach") || !slices.Contains(dockerNeeds, "form:seat-reach") { t.Fatalf("the new manifest needs %v and docker's %v, want form:seat-reach in both (mesh-sdk ecdbf1a, ModuleNeeds)", needs, dockerNeeds) diff --git a/modules/mesh-delivery-indexer/internal/index/index_test.go b/modules/mesh-delivery-indexer/internal/index/index_test.go index 0a20d6f8..92fbeebb 100644 --- a/modules/mesh-delivery-indexer/internal/index/index_test.go +++ b/modules/mesh-delivery-indexer/internal/index/index_test.go @@ -185,7 +185,7 @@ func TestModuleOfADirectory(t *testing.T) { // 1238/AC3, as R4e has it: the modu ctx := context.Background() // The trunk holds modules/odd as the module odd-name (its manifest names it so); modules/plain is held by none. merged := mergedMsg("novox/mesh-delivery", map[string]string{"modules/odd": "t"}) - merged.Manifests["modules/odd"] = `{"module":"odd-name","version":"1"}` + merged.Manifests["modules/odd"] = `{"module":"odd-name","version":"1.0"}` if err := ix.Apply(ctx, merged); err != nil { t.Fatal(err) } @@ -202,7 +202,7 @@ func TestModuleOfADirectory(t *testing.T) { // 1238/AC3, as R4e has it: the modu } // A manifest the pull message carries names its module, whatever directory it is in. f := pull("novox/mesh-delivery", 13, "feat/y", h2, "modules/odd") - f.Manifests = map[string]string{"modules/odd": `{"module":"renamed","version":"1"}`} + f.Manifests = map[string]string{"modules/odd": `{"module":"renamed","version":"1.0"}`} if name := moduleOfDir(held, f, "modules/odd"); name != "renamed" { t.Errorf("modules/odd = %q, want the module its manifest names", name) } diff --git a/modules/mesh-delivery-indexer/internal/index/inputs_test.go b/modules/mesh-delivery-indexer/internal/index/inputs_test.go index 77adda62..74763916 100644 --- a/modules/mesh-delivery-indexer/internal/index/inputs_test.go +++ b/modules/mesh-delivery-indexer/internal/index/inputs_test.go @@ -144,7 +144,7 @@ func TestAnArtifactOfNoToolchainHasNoLanguageAndNoLock(t *testing.T) { // 1450/A func TestAManifestTheParserRefusesLeavesNoInputsAndSaysWhy(t *testing.T) { // 1450/AC5 for name, raw := range map[string]string{ "not json": "not json", - "unknown field": strings.Replace(catalogueManifest(t, "avahi"), `"version": "1",`, `"version": "1", "scope": "everything",`, 1), + "unknown field": strings.Replace(catalogueManifest(t, "avahi"), `"version":"1.0",`, `"version":"1.0", "scope": "everything",`, 1), } { in := inputsOf("avahi", raw, "t", nil, noBases) if in.Kept != nil || !strings.HasPrefix(in.None2, "the manifest cannot be read: ") || len(in.None2) <= len("the manifest cannot be read: ") { diff --git a/modules/mesh-delivery-indexer/internal/index/modules_test.go b/modules/mesh-delivery-indexer/internal/index/modules_test.go index 1381e39b..1d3b78fe 100644 --- a/modules/mesh-delivery-indexer/internal/index/modules_test.go +++ b/modules/mesh-delivery-indexer/internal/index/modules_test.go @@ -82,7 +82,7 @@ func rec(t *testing.T, id, module, repo, path, ref, state, at, inputs string) [2 func mergedMsg(repo string, trees map[string]string) forgemsg.Forge { manifests := map[string]string{} for dir := range trees { - manifests[dir] = `{"module":"` + nameOfDir(dir) + `","version":"1"}` + manifests[dir] = `{"module":"` + nameOfDir(dir) + `","version":"1.0"}` } return forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgeMerged, Repository: repo, Branch: "feat/x", Number: 12, Head: h1, Merge: merge, Root: "r0", Trees: trees, Manifests: manifests} } @@ -515,7 +515,7 @@ func TestAModuleMovesBetweenRepositoriesOnlyWhenItsTrunkDropsIt(t *testing.T) { if repository != to || sha != toHead || dir != "modules/a" { t.Errorf("read %s at %s %q, want only %s at %s modules/a", repository, sha, dir, to, toHead) } - return RepoRead{Root: "r2", Trees: map[string]string{dir: "t2"}, Manifests: map[string]string{dir: `{"module":"a","version":"1"}`}}, nil + return RepoRead{Root: "r2", Trees: map[string]string{dir: "t2"}, Manifests: map[string]string{dir: `{"module":"a","version":"1.0"}`}}, nil } } for _, f := range tc.merges { diff --git a/modules/mesh-delivery-indexer/internal/index/named_test.go b/modules/mesh-delivery-indexer/internal/index/named_test.go index d492a5af..72623093 100644 --- a/modules/mesh-delivery-indexer/internal/index/named_test.go +++ b/modules/mesh-delivery-indexer/internal/index/named_test.go @@ -106,7 +106,7 @@ func TestTheIndexerAsksTheForgeForTheNamedPathsObjectsOfAMerge(t *testing.T) { / return map[string]map[string]string{"": sdkObjects}, nil } raw := fixture(t, "sdk-9cb4d59-mesh-sdk") - f := withManifests(mergedMsg("novox/mesh-sdk", map[string]string{"": "roottree", "modules/a": "t"}), map[string]string{"": raw, "modules/a": `{"module":"a","version":"1"}`}, nil) + f := withManifests(mergedMsg("novox/mesh-sdk", map[string]string{"": "roottree", "modules/a": "t"}), map[string]string{"": raw, "modules/a": `{"module":"a","version":"1.0"}`}, nil) if err := ix.Apply(context.Background(), f); err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-indexer/internal/index/pending_test.go b/modules/mesh-delivery-indexer/internal/index/pending_test.go index 27bff288..0d62d0c5 100644 --- a/modules/mesh-delivery-indexer/internal/index/pending_test.go +++ b/modules/mesh-delivery-indexer/internal/index/pending_test.go @@ -365,7 +365,7 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633) f.Locks[d] = l } f.Locks[contracts] = map[string]string{} - f.Manifests[contracts] = `{"module":"` + row.module + `","version":"1","build":{"artifacts":[{"name":"go-seats","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}` + f.Manifests[contracts] = `{"module":"` + row.module + `","version":"1.0","build":{"artifacts":[{"name":"go-seats","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}` b.ix.Files = func(_ context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error) { if repository != f.Repository || commit != f.Merge || !slices.Equal(dirs, []string{contracts + "/seats"}) { t.Errorf("listed %v of %s at %s", dirs, repository, commit) diff --git a/modules/mesh-delivery-indexer/internal/index/registered_test.go b/modules/mesh-delivery-indexer/internal/index/registered_test.go index 4e1cd9ee..6492329e 100644 --- a/modules/mesh-delivery-indexer/internal/index/registered_test.go +++ b/modules/mesh-delivery-indexer/internal/index/registered_test.go @@ -15,7 +15,7 @@ import ( func TestARegisteredModuleIsIndexedFromItsRepository(t *testing.T) { // the grants part's gaps: modules registered from their own repositories had no manifest here const forge = "http://novox.internal:20000/jschoubben/snake.git" - snake := `{"module":"snake","version":"1"}` + snake := `{"module":"snake","version":"1.0"}` for _, tc := range []struct { name string path string @@ -41,7 +41,7 @@ func TestARegisteredModuleIsIndexedFromItsRepository(t *testing.T) { // the gran {wantRead: true, name: "no manifest at the path", path: "modules/x", read: RepoRead{Root: "r0", Trees: map[string]string{"": "t1"}, Manifests: map[string]string{"": snake}}}, {wantRead: true, name: "a manifest of another module", path: "", - read: RepoRead{Root: "r0", Trees: map[string]string{"": "t1"}, Manifests: map[string]string{"": `{"module":"other","version":"1"}`}}}, + read: RepoRead{Root: "r0", Trees: map[string]string{"": "t1"}, Manifests: map[string]string{"": `{"module":"other","version":"1.0"}`}}}, {wantRead: true, name: "a repository whose default branch is master", path: "", ref: "master", want: true, read: RepoRead{Root: "r0", Trees: map[string]string{"": "t1"}, Manifests: map[string]string{"": snake}}}, {wantRead: true, name: "a repository built from a branch of its own (nox-mesh)", path: "", ref: "nox-mesh", want: true, @@ -151,7 +151,7 @@ func TestARegisteredModuleIsIndexedFromItsRepository(t *testing.T) { // the gran var asked []string ix.Read = func(_ context.Context, _, sha, dir string) (RepoRead, error) { asked = append(asked, sha+":"+dir) - return RepoRead{Root: "r1", Trees: map[string]string{"modules/a": "treeA2"}, Manifests: map[string]string{"modules/a": `{"module":"a","version":"2"}`}}, nil + return RepoRead{Root: "r1", Trees: map[string]string{"modules/a": "treeA2"}, Manifests: map[string]string{"modules/a": `{"module":"a","version":"2.0"}`}}, nil } got := ix.TakeIn(ctx, "a") m, ok := moduleOf(t, mods, "a") @@ -202,7 +202,7 @@ func TestARegistrationOfAnotherRepositoryDoesNotHideTheRealOne(t *testing.T) { / ix.Read = func(_ context.Context, repo, _, dir string) (RepoRead, error) { asked = append(asked, repo+":"+dir) if repo == "novox/Link2Pay" { - return RepoRead{Root: "r0", Trees: map[string]string{"": "t1"}, Manifests: map[string]string{"": `{"module":"link2pay","version":"1"}`}}, nil + return RepoRead{Root: "r0", Trees: map[string]string{"": "t1"}, Manifests: map[string]string{"": `{"module":"link2pay","version":"1.0"}`}}, nil } return RepoRead{Root: "r0"}, nil } @@ -221,7 +221,7 @@ func TestARegistrationOfAnotherRepositoryDoesNotHideTheRealOne(t *testing.T) { / func TestAForgeThatCannotSayLeavesTheModuleUnwrittenAndAsksAgain(t *testing.T) { // a timeout or a 404 at the head: no read at an older commit // Issue 1945: car-hunter and snake answered 404 until the forge's account could read them, and were read again only when // the indexer started again. Each row's trigger must now take the module in once the forge reads it. - const snake = `{"module":"snake","version":"1"}` + const snake = `{"module":"snake","version":"1.0"}` ctx := context.Background() for _, tc := range []struct{ failure, trigger string }{ {"timeout", "the indexer starts again"}, diff --git a/modules/mesh-delivery-indexer/internal/index/stale_none2_test.go b/modules/mesh-delivery-indexer/internal/index/stale_none2_test.go index e92d94b7..6ec511d1 100644 --- a/modules/mesh-delivery-indexer/internal/index/stale_none2_test.go +++ b/modules/mesh-delivery-indexer/internal/index/stale_none2_test.go @@ -10,7 +10,7 @@ import ( // not read. func unreadableAvahi(t *testing.T) string { t.Helper() - raw := strings.Replace(catalogueManifest(t, "avahi"), `"version": "1",`, `"version": "1", "scope": "everything",`, 1) + raw := strings.Replace(catalogueManifest(t, "avahi"), `"version":"1.0",`, `"version":"1.0", "scope": "everything",`, 1) if raw == catalogueManifest(t, "avahi") { t.Fatal("the fixture has no version line") } diff --git a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go index 762649d9..16eb83ce 100644 --- a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go +++ b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go @@ -344,7 +344,7 @@ func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { // // verb edges of two modules calling each other, from either side; a third calling one of them keeps its edge. invoking := func(name, invokes string) mod { raw := renamed(t, "avahi", name) - return mod{name, strings.Replace(raw, `"version": "1",`, `"version": "1", "invokes": [`+invokes+`],`, 1)} + return mod{name, strings.Replace(raw, `"version":"1.0",`, `"version":"1.0", "invokes": [`+invokes+`],`, 1)} } vx, _ := newFormIndexer(t, invoking("caller", `"callee.avahi_status"`), invoking("callee", `"caller.avahi_browse", "*"`), invoking("third", `"callee.avahi_status"`)) @@ -505,7 +505,7 @@ func TestAModuleKeepsTheToolchainsItsFingerprintWasComputedWith(t *testing.T) { sdkMerge := func(commit, tree string) meshcatalog.ModulesByModuleValue { t.Helper() f := forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-sdk", Branch: "feat/x", Number: 3, Head: h1, - Merge: commit, Root: tree, Trees: map[string]string{"": tree}, Manifests: map[string]string{"": `{"module":"mesh-sdk","version":"1"}`}} + Merge: commit, Root: tree, Trees: map[string]string{"": tree}, Manifests: map[string]string{"": `{"module":"mesh-sdk","version":"1.0"}`}} if err := ix.Apply(context.Background(), f); err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go index c1d529ac..9650ee12 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go @@ -40,8 +40,8 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { ix.Modules.(*memStore).put(module, value) return digest } - good := hold("good", `{"module":"good","version":"1"}`) - bad := hold("bad", `{"module":"bad","version":"1","requires":["amqp"]}`) + good := hold("good", `{"module":"good","version":"1.0"}`) + bad := hold("bad", `{"module":"bad","version":"1.0","requires":["amqp"]}`) // Both taken in by a merge: judged in full. j, err := ix.JudgeAll(ctx, map[string]bool{good: true, bad: true}) if err != nil || j.Judged != 2 || j.Written != 2 || len(j.Refused) != 1 { @@ -91,7 +91,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { t.Fatalf("the new indexer at its start: %+v %v, said %d, want both manifests judged and nothing written", j, err, len(said)) } // The manifest mended: its new digest is accepted and the module's refusal goes. - mended := hold("bad", `{"module":"bad","version":"2"}`) + mended := hold("bad", `{"module":"bad","version":"2.0"}`) if j, err := ix.JudgeAll(ctx, map[string]bool{mended: true}); err != nil || j.Written != 1 || len(j.Refused) != 0 { t.Fatalf("mended: %+v %v", j, err) } @@ -101,7 +101,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { // ADR 0579 §1: a manifest held before any verdict (taken in under the judges until then), which this judge finds a // problem in, stays accepted with the problem as a warning, carried in its value and said nowhere as a refusal; the // same problem in a manifest a merge takes in now, or in one whose digest changed, is refused in full. - old := hold("old", `{"module":"old","version":"1","requires":["amqp"]}`) + old := hold("old", `{"module":"old","version":"1.0","requires":["amqp"]}`) if j, err := ix.JudgeAll(ctx, nil); err != nil || len(j.Outgrown) != 1 || j.Outgrown[0] != "old" { t.Fatalf("an outgrown manifest: %+v %v", j, err) } @@ -111,8 +111,8 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { if m := module("old"); m.Verdict != "" || len(m.Warnings) != 1 || len(said) != 1 { t.Fatalf("modules.old carries %+v, said %d", m, len(said)) } - changed := hold("old", `{"module":"old","version":"2","requires":["amqp"]}`) - fresh := hold("new", `{"module":"new","version":"1","requires":["amqp"]}`) + changed := hold("old", `{"module":"old","version":"2.0","requires":["amqp"]}`) + fresh := hold("new", `{"module":"new","version":"1.0","requires":["amqp"]}`) if j, err := ix.JudgeAll(ctx, map[string]bool{changed: true, fresh: true}); err != nil || len(j.Outgrown) != 0 { t.Fatalf("a changed and a new manifest: %+v %v", j, err) } @@ -142,7 +142,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { // manifest a judge refuses in full, its judge pass fails on the verdicts state, and the next pass, which knows nothing // of that take-in (a start, a pass kicked by a message), still refuses it in full, never as a warning, and ends the key. ix.Unjudged = newMem() - keptDigest, err := ix.keepManifest(ctx, "kept", `{"module":"kept","version":"1","requires":["amqp"]}`) + keptDigest, err := ix.keepManifest(ctx, "kept", `{"module":"kept","version":"1.0","requires":["amqp"]}`) if err != nil { t.Fatal(err) } @@ -182,7 +182,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { accepted, ids = append(accepted, a), append(ids, id) return nil } - moved := hold("moved", `{"module":"moved","version":"1","requires":[`+ + moved := hold("moved", `{"module":"moved","version":"1.0","requires":[`+ `{"name":"cache","storage":"key-value","template":"state","options":{"history":8}},`+ `{"name":"audit","storage":"stream","template":"log","options":{"max-mib":64}}]}`) for pass := 0; pass < 2; pass++ { @@ -208,7 +208,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { t.Fatalf("said accepted %+v", accepted) } // DLV-I9 (design 25 § 12.2): a manifest asking for none, newly accepted, is said too, its entries an empty list. - plain := hold("plain", `{"module":"plain","version":"1"}`) + plain := hold("plain", `{"module":"plain","version":"1.0"}`) if _, err := ix.JudgeAll(ctx, map[string]bool{plain: true}); err != nil { t.Fatal(err) } @@ -217,8 +217,8 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { } // Review round 1 of issue 1966: a trunk's held manifest judged by its digest (indexer_judge, judgeStale) is said as the // full pass says it, accepted or refused; a candidate's digest judged over the trunk is said nowhere. - byDigest := hold("bydigest", `{"module":"bydigest","version":"1"}`) - refusedBy := hold("refusedby", `{"module":"refusedby","version":"1","requires":["amqp"]}`) + byDigest := hold("bydigest", `{"module":"bydigest","version":"1.0"}`) + refusedBy := hold("refusedby", `{"module":"refusedby","version":"1.0","requires":["amqp"]}`) saidBefore := len(said) for module, digest := range map[string]string{"bydigest": byDigest, "refusedby": refusedBy} { if _, err := ix.judgeOver(ctx, nil, module, digest); err != nil { @@ -228,7 +228,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { if last := accepted[len(accepted)-1]; last.Digest != byDigest || len(said) != saidBefore+1 || said[len(said)-1].Digest != refusedBy { t.Fatalf("judged by its digest, the trunk's manifest was said as %+v, refusals %d (were %d)", last, len(said), saidBefore) } - candidate, err := ix.keepManifest(ctx, "bydigest", `{"module":"bydigest","version":"2"}`) + candidate, err := ix.keepManifest(ctx, "bydigest", `{"module":"bydigest","version":"2.0"}`) if err != nil { t.Fatal(err) } @@ -239,12 +239,12 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { // judging pass says again and clears. ix.Findings = newMem() failing = 1 - once := hold("once", `{"module":"once","version":"1"}`) + once := hold("once", `{"module":"once","version":"1.0"}`) if _, err := ix.JudgeAll(ctx, map[string]bool{once: true}); err != nil || accepted[len(accepted)-1].Module != "once" { t.Fatalf("a publish failing once was not published again: %v %+v", err, accepted[len(accepted)-1]) } failing = 2 - flaky := hold("flaky", `{"module":"flaky","version":"1"}`) + flaky := hold("flaky", `{"module":"flaky","version":"1.0"}`) if _, err := ix.JudgeAll(ctx, map[string]bool{flaky: true}); err != nil { t.Fatal(err) } @@ -277,13 +277,13 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { } // DLV-I15: a change's renamed-from is judged against the trunk's manifest of the module: a name the trunk declares is // accepted, one it does not is refused naming the key. - trunkRenamer := hold("renamer", `{"module":"renamer","version":"1","config":{"font-name":{"type":"string","description":"The font.","required":true,"runtime":true}}}`) + trunkRenamer := hold("renamer", `{"module":"renamer","version":"1.0","config":{"font-name":{"type":"string","description":"The font.","required":true,"runtime":true}}}`) if _, err := ix.JudgeAll(ctx, map[string]bool{trunkRenamer: true}); err != nil { t.Fatal(err) } for raw, want := range map[string]string{ - `{"module":"renamer","version":"1","config":{"font":{"type":"string","description":"The font.","required":true,"runtime":true,"renamed-from":"font-name"}}}`: "", - `{"module":"renamer","version":"1","config":{"font":{"type":"string","description":"The font.","required":true,"runtime":true,"renamed-from":"typeface"}}}`: `renamed from "typeface"`, + `{"module":"renamer","version":"1.0","config":{"font":{"type":"string","description":"The font.","required":true,"runtime":true,"renamed-from":"font-name"}}}`: "", + `{"module":"renamer","version":"1.0","config":{"font":{"type":"string","description":"The font.","required":true,"runtime":true,"renamed-from":"typeface"}}}`: `renamed from "typeface"`, } { head, err := ix.keepManifest(ctx, "renamer", raw) if err != nil { @@ -305,8 +305,8 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { ix.Packages.(*memStore).put(SeriesKey(sdkGo, "0.1"), value) } for raw, refusedAtMerge := range map[string]bool{ - `{"module":"pkguser","version":"1","packages":{"@novox/ui":"0.1"}}`: true, - `{"module":"pkguser","version":"2","packages":{"` + sdkGo + `":"0.1"}}`: false, + `{"module":"pkguser","version":"1.0","packages":{"@novox/ui":"0.1"}}`: true, + `{"module":"pkguser","version":"2.0","packages":{"` + sdkGo + `":"0.1"}}`: false, } { head, err := ix.keepManifest(ctx, "pkguser", raw) if err != nil { @@ -328,8 +328,8 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { // 1966/AC11: modules using state and none using logs but the foundation: logs counts 0, so a manifest of another // module using logs is refused and the foundation's is accepted. ix.OldSections = newMem() - hold("user", `{"module":"user","version":"1","state":["x"]}`) - hold("mesh-grants", `{"module":"mesh-grants","version":"1","logs":[{"name":"l"}]}`) + hold("user", `{"module":"user","version":"1.0","state":["x"]}`) + hold("mesh-grants", `{"module":"mesh-grants","version":"1.0","logs":[{"name":"l"}]}`) if _, err := ix.JudgeAll(ctx, nil); err != nil { t.Fatal(err) } @@ -348,7 +348,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { t.Fatalf("the old sections count %v, want state 1 and the rest 0", c) } // DLV-J2, DLV-J3 (ADR 0615 §1): one reads entry on the assigner's state counts 1 for the assigner and 0 for the indexer. - hold("reader", `{"module":"reader","version":"1","state":["x"],"reads":["mesh-delivery-assigner.wants"]}`) + hold("reader", `{"module":"reader","version":"1.0","state":["x"],"reads":["mesh-delivery-assigner.wants"]}`) if _, err := ix.JudgeAll(ctx, nil); err != nil { t.Fatal(err) } @@ -362,7 +362,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { // Design 47 *A module's registration* (b229f5c4 lines 480-481): a template in the manifest of every part on the list is // refused, the runner's included. for _, part := range []string{"mesh-config", "mesh-delivery-pipeline"} { - d := hold(part, `{"module":"`+part+`","version":"1","requires":[{"name":"cache","storage":"key-value","template":"state"}]}`) + d := hold(part, `{"module":"`+part+`","version":"1.0","requires":[{"name":"cache","storage":"key-value","template":"state"}]}`) if _, err := ix.JudgeAll(ctx, map[string]bool{d: true}); err != nil { t.Fatal(err) } @@ -374,8 +374,8 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { "mesh-delivery-builder", "mesh-delivery-checker", "mesh-config", "mesh-vault", "mesh-delivery-pipeline"}) { t.Fatalf("the foundation is %v, want exactly the nine of ADR 0619 and the pipeline runner (ADR 0666 §4)", judge.Foundation) } - late := hold("late", `{"module":"late","version":"1","logs":[{"name":"l"}]}`) - grants := hold("mesh-grants", `{"module":"mesh-grants","version":"2","logs":[{"name":"l"},{"name":"m"}]}`) + late := hold("late", `{"module":"late","version":"1.0","logs":[{"name":"l"}]}`) + grants := hold("mesh-grants", `{"module":"mesh-grants","version":"2.0","logs":[{"name":"l"},{"name":"m"}]}`) if _, err := ix.JudgeAll(ctx, map[string]bool{late: true, grants: true}); err != nil { t.Fatal(err) } @@ -387,11 +387,11 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { } // 1966/AC11: the sole user of an old section changes its manifest and keeps the section: accepted, since it was accepted // with it (the count is the accepted set held from the last pass, not recounted from fresh digests). - hold("solo", `{"module":"solo","version":"1","objects":[{"name":"o","max-mib":1}]}`) + hold("solo", `{"module":"solo","version":"1.0","objects":[{"name":"o","max-mib":1}]}`) if _, err := ix.JudgeAll(ctx, nil); err != nil { t.Fatal(err) } - solo2 := hold("solo", `{"module":"solo","version":"2","objects":[{"name":"o","max-mib":2}]}`) + solo2 := hold("solo", `{"module":"solo","version":"2.0","objects":[{"name":"o","max-mib":2}]}`) if _, err := ix.JudgeAll(ctx, map[string]bool{solo2: true}); err != nil { t.Fatal(err) } @@ -403,7 +403,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { ix.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } held := ix.OldSections ix.OldSections = unreadable{held} - retry := hold("late", `{"module":"late","version":"2","logs":[{"name":"l"}]}`) + retry := hold("late", `{"module":"late","version":"2.0","logs":[{"name":"l"}]}`) if _, err := ix.JudgeAll(ctx, map[string]bool{retry: true}); err != nil { t.Fatal(err) } @@ -415,9 +415,9 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { // event) while a newer build of the indexer is on the trunk, and is judged again at the next judge; once this program's // build is the trunk's newest it is refused; a wait is written into the module's value as a refusal naming the fields. ownFP := "src2:" + strings.Repeat("e", 64) - hold(Module, `{"module":"mesh-delivery-indexer","version":"1"}`) + hold(Module, `{"module":"mesh-delivery-indexer","version":"1.0"}`) ix.ownFP = ownFP // this program started when the trunk held another build of the indexer than the one it holds now - later := hold("later", `{"module":"later","version":"1","a-field-from-later":1,"another":{"x":1}}`) + later := hold("later", `{"module":"later","version":"1.0","a-field-from-later":1,"another":{"x":1}}`) said = nil before := len(said) j, err = ix.JudgeAll(ctx, map[string]bool{later: true}) @@ -450,7 +450,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { } ix.ownFP = ownFP // A renamed field is a known mistake: refused at once. - renamed := hold("renamed", `{"module":"renamed","version":"1","needs":{"x":"y"}}`) + renamed := hold("renamed", `{"module":"renamed","version":"1.0","needs":{"x":"y"}}`) if j, err := ix.JudgeAll(ctx, map[string]bool{renamed: true}); err != nil || len(j.Waiting) != 1 || !meshcatalogclient.VerdictRefuses(verdict(renamed)) { t.Fatalf("a renamed field: %+v %v", j, err) } @@ -513,8 +513,8 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { } return digest } - atStart := stale("tools", `{"module":"tools","version":"7"}`) - ownRefusal := stale("client", `{"module":"client","version":"7"}`) + atStart := stale("tools", `{"module":"tools","version":"7.0"}`) + ownRefusal := stale("client", `{"module":"client","version":"7.0"}`) if _, _, err := ix.writeVerdict(ctx, ownRefusal, meshcatalog.VerdictsByDigestValue{Module: "client", State: meshcatalogclient.VerdictRefused, Problems: []string{"x"}, Judge: ix.judgeID(), At: ptr(time.Now())}); err != nil { t.Fatal(err) @@ -522,13 +522,13 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { if _, err := ix.JudgeAtStart(ctx); err != nil || meshcatalogclient.VerdictRefuses(verdict(atStart)) || verdict(atStart).Judge != ix.judgeID() || !meshcatalogclient.VerdictRefuses(verdict(ownRefusal)) { t.Fatalf("the start pass: %+v, this judge's own refusal %+v (%v)", verdict(atStart), verdict(ownRefusal), err) } - byPull := stale("tools", `{"module":"tools","version":"8"}`) + byPull := stale("tools", `{"module":"tools","version":"8.0"}`) pull := forgemsg.Forge{Kind: forgemsg.ForgePull, Repository: "novox/mesh-tools", Number: 3, Branch: "main", - Manifests: map[string]string{"modules/tools": `{"module":"tools","version":"8"}`}} + Manifests: map[string]string{"modules/tools": `{"module":"tools","version":"8.0"}`}} if _, err := ix.ApplyMessage(ctx, pull); err != nil || meshcatalogclient.VerdictRefuses(verdict(byPull)) || verdict(byPull).Judge != ix.judgeID() { t.Fatalf("a pull carrying a digest an older judge refused: %+v %v", verdict(byPull), err) } - byTool := stale("tools", `{"module":"tools","version":"9"}`) + byTool := stale("tools", `{"module":"tools","version":"9.0"}`) if got := ix.JudgeAgain(ctx, "client", byTool); got.Stored || !strings.Contains(got.Answer, "tools's, not client's") || !meshcatalogclient.VerdictRefuses(verdict(byTool)) { t.Fatalf("indexer_judge with another module's digest: %+v", got) } diff --git a/modules/mesh-delivery-indexer/judge/busstate_test.go b/modules/mesh-delivery-indexer/judge/busstate_test.go index 8f68a4b7..bf214994 100644 --- a/modules/mesh-delivery-indexer/judge/busstate_test.go +++ b/modules/mesh-delivery-indexer/judge/busstate_test.go @@ -17,7 +17,7 @@ func TestBusStateIsJudgedOnce(t *testing.T) { const ledger = `{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":512},"types":[` + `{"name":"decision","schema":{"title":{"type":"text"}},"operations":[{"name":"propose","effect":"set-fields","fields":["title"]}]}]}` manifest := func(entries string) string { - return `{"module":"a","version":"1","requires":[` + entries + `]}` + return `{"module":"a","version":"1.0","requires":[` + entries + `]}` } for _, c := range []struct { name string @@ -30,7 +30,7 @@ func TestBusStateIsJudgedOnce(t *testing.T) { `{"name":"files","storage":"object-store","template":"objects","options":{"max-mib":2048}},` + ledger), nil}, // Design 47: every kept data may say its form and its catch-up tool, in an old section and in `requires` by template, // and is accepted as read; one saying neither reads as before. - {"a form and a catch-up tool on every kept kind", `{"module":"a","version":"1","tools":["a_take_in"],` + + {"a form and a catch-up tool on every kept kind", `{"module":"a","version":"1.0","tools":["a_take_in"],` + `"state":["seen",{"name":"modules","form":1,"catch-up":"a_take_in"}],"logs":[{"name":"acts","form":2}],` + `"streams":[{"name":"forge","events":["merged"],"form":1,"catch-up":"a_take_in"}],` + `"objects":[{"name":"revisions","max-mib":64,"form":1,"catch-up":"a_take_in"}],"requires":[` + @@ -40,9 +40,9 @@ func TestBusStateIsJudgedOnce(t *testing.T) { `{"name":"blobs","storage":"object-store","template":"objects","options":{"max-mib":64},"form":4,"catch-up":"a_take_in"},` + `{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":512},"form":3,"catch-up":"a_take_in","types":[` + `{"name":"decision","schema":{"title":{"type":"text"}},"operations":[{"name":"propose","effect":"set-fields","fields":["title"]}]}]}]}`, nil}, - {"a catch-up tool the module lacks", `{"module":"a","version":"1","tools":["a_read"],"logs":[{"name":"acts","form":1,"catch-up":"a_take_in"}]}`, + {"a catch-up tool the module lacks", `{"module":"a","version":"1.0","tools":["a_read"],"logs":[{"name":"acts","form":1,"catch-up":"a_take_in"}]}`, []string{"a's kept log \"acts\"", "catch-up tool \"a_take_in\" is not among the module's tools (a_read)"}}, - {"a catch-up tool the module lacks, in requires", `{"module":"a","version":"1","tools":["a_read"],"requires":[` + + {"a catch-up tool the module lacks, in requires", `{"module":"a","version":"1.0","tools":["a_read"],"requires":[` + `{"name":"holds","storage":"key-value","template":"state","form":1,"catch-up":"nonexistent"}]}`, []string{"a's kept state \"holds\"", "catch-up tool \"nonexistent\""}}, {"a form below 0", manifest(`{"name":"x","storage":"key-value","template":"state","form":-1}`), []string{"requires entry \"x\"", "its form is -1"}}, @@ -127,13 +127,13 @@ func TestBusStateIsJudgedOnce(t *testing.T) { "reader": {Module: "reader", Reads: []string{"owner.cache"}}}, ""}, // DLV-I14 (design 32): the packages section names a known package, by its name in its own language (the packages // state's names, and a group member's at its head: known), in a well-formed series; through a judge request too. - {"packages naming a known package", catalogue.Shelf{"mesh-sdk": sdk, "user": {Module: "user", Packages: map[string]string{sdkGo: "0.2"}}}, ""}, - {"packages naming a known package, by request", catalogue.Shelf{"user": {Module: "user", Packages: map[string]string{sdkGo: "0.2"}}}, ""}, - {"packages naming a package by the builder's name", catalogue.Shelf{"mesh-sdk": sdk, "user": {Module: "user", Packages: map[string]string{"mesh-sdk/go": "0.1"}}}, + {"packages naming a known package", catalogue.Shelf{"mesh-sdk": sdk, "user": {Module: "user", Version: "1.0", Packages: map[string]string{sdkGo: "0.2"}}}, ""}, + {"packages naming a known package, by request", catalogue.Shelf{"user": {Module: "user", Version: "1.0", Packages: map[string]string{sdkGo: "0.2"}}}, ""}, + {"packages naming a package by the builder's name", catalogue.Shelf{"mesh-sdk": sdk, "user": {Module: "user", Version: "1.0", Packages: map[string]string{"mesh-sdk/go": "0.1"}}}, `user's packages name "mesh-sdk/go", which no package of the mesh is named`}, - {"packages naming an unknown package, by request", catalogue.Shelf{"user": {Module: "user", Packages: map[string]string{"@novox/ui": "0.1"}}}, + {"packages naming an unknown package, by request", catalogue.Shelf{"user": {Module: "user", Version: "1.0", Packages: map[string]string{"@novox/ui": "0.1"}}}, `user's packages name "@novox/ui", which no package of the mesh is named`}, - {"packages in an ill-formed series", catalogue.Shelf{"mesh-sdk": sdk, "user": {Module: "user", Packages: map[string]string{sdkGo: "v0.2"}}}, + {"packages in an ill-formed series", catalogue.Shelf{"mesh-sdk": sdk, "user": {Module: "user", Version: "1.0", Packages: map[string]string{sdkGo: "v0.2"}}}, `user's packages accept "` + sdkGo + `" in the series "v0.2"`}, } { got := strings.Join(append(BusStateCatalogueProblems(c.shelf), PackagesCatalogueProblems(c.shelf, known)...), "\n") diff --git a/modules/mesh-delivery-indexer/judge/g_words_test.go b/modules/mesh-delivery-indexer/judge/g_words_test.go new file mode 100644 index 00000000..37d8267e --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/g_words_test.go @@ -0,0 +1,33 @@ +package judge + +import ( + "git.novox.be/novox/mesh-sdk/go/catalogue" + "strings" + "testing" +) + +// Design 47 G §368, §386, §387, §89: the manifest validator reads the manifests' new words before any manifest uses them. +func TestTheJudgeReadsMilestoneGsWords(t *testing.T) { + for _, row := range []struct { + name string + raw string + want string // a word of the first problem; empty: accepted + }{ + {name: "MAJOR.MINOR", raw: `{"module":"a","version":"1.4"}`}, + {name: "a pre-release suffix", raw: `{"module":"a","version":"1.4-beta"}`, want: "version"}, + {name: "three numbers", raw: `{"module":"a","version":"1.4.2"}`, want: "version"}, + {name: "a findings state without its stream", raw: `{"module":"a","version":"1.0","state":["findings"]}`, want: "findings"}, + {name: "a findings state with its stream and ask", raw: `{"module":"a","version":"1.0","state":["findings"],` + + `"streams":[{"name":"a-findings","events":["finding-written"],"retention":"history"}],"consumes":["mesh-conditions.findings-asked"]}`}, + } { + raws := map[string][]byte{"a": []byte(row.raw)} + var refused map[string][]string + WithSeats([]catalogue.Seat{{Name: "thing", Scope: catalogue.ScopeMesh, Emits: []string{"finding-written"}}}, func() { + refused, _ = Shelf(raws, nil, 0) + }) + got := strings.Join(refused["a"], "\n") + if (row.want == "") != (got == "") || !strings.Contains(got, row.want) { + t.Errorf("%s: refused with %q, want %q", row.name, got, row.want) + } + } +} diff --git a/modules/mesh-delivery-indexer/judge/gwords.go b/modules/mesh-delivery-indexer/judge/gwords.go new file mode 100644 index 00000000..12088b74 --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/gwords.go @@ -0,0 +1,54 @@ +package judge + +import ( + "fmt" + "regexp" + "slices" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The manifests' words milestone G adds, read before any manifest uses them (design 47 G §89: the manifest validator's +// reading of the new words is an edge of kind 4 that the delivery plan derives). + +// majorMinor is a manifest's version: two numbers (design 47 G §368). No pre-release or custom suffix exists. +var majorMinor = regexp.MustCompile(`^[0-9]+\.[0-9]+$`) + +// FindingsAsked is the event an observer consumes to say its findings again (design 47 G §387). +const FindingsAsked = "mesh-conditions.findings-asked" + +// FindingStreamOf is the stream an observer says its findings on (design 47 G §383). +func FindingStreamOf(module string) string { return module + "-findings" } + +// versionProblems refuses a manifest whose `version` is not two numbers MAJOR.MINOR, or is missing (design 47 G §368). The +// judge's entry (Manifest) holds a manifest to it; the parser the inputs of a fingerprint are read by does not. +func versionProblems(m catalogue.Manifest) []string { + if majorMinor.MatchString(m.Version) { + return nil + } + return []string{fmt.Sprintf("%s says its version as %q; a manifest names `version` as two numbers MAJOR.MINOR such as 1.4, "+ + "and no pre-release or custom suffix exists (design 47 G §368)", m.Module, m.Version)} +} + +// gWordsProblems are the problems of a manifest's words milestone G adds: the finding stream and +// ask of a module that declares a state `findings` (§387). The `*` emitter of `consumes` (§386) is judged with the +// other consumed patterns (consumePattern). +func gWordsProblems(m catalogue.Manifest) []string { + var problems []string + for _, s := range m.State { + if s.Name != "findings" { + continue + } + stream := FindingStreamOf(m.Module) + if !slices.ContainsFunc(m.Streams, func(d catalogue.StreamDeclaration) bool { return d.Name == stream }) { + problems = append(problems, fmt.Sprintf("%s declares the state findings without the stream %q its findings are said on "+ + "(design 47 G §387)", m.Module, stream)) + } + if !slices.Contains(m.Consumes, FindingsAsked) { + problems = append(problems, fmt.Sprintf("%s declares the state findings without consuming %q, with which the "+ + "keeper asks its findings again (design 47 G §387)", m.Module, FindingsAsked)) + } + break + } + return problems +} diff --git a/modules/mesh-delivery-indexer/judge/judge.go b/modules/mesh-delivery-indexer/judge/judge.go index 59c4921b..d20f441d 100644 --- a/modules/mesh-delivery-indexer/judge/judge.go +++ b/modules/mesh-delivery-indexer/judge/judge.go @@ -45,6 +45,9 @@ func Manifest(raw []byte, predefined catalogue.PredefinedList) (m catalogue.Mani if err != nil { return catalogue.Manifest{}, problemsOf(err) } + if problems = versionProblems(m); len(problems) > 0 { + return catalogue.Manifest{}, problems + } return m, nil } diff --git a/modules/mesh-delivery-indexer/judge/judge_test.go b/modules/mesh-delivery-indexer/judge/judge_test.go index 1acdc24e..4dbd39c0 100644 --- a/modules/mesh-delivery-indexer/judge/judge_test.go +++ b/modules/mesh-delivery-indexer/judge/judge_test.go @@ -20,30 +20,30 @@ func TestTheJudge(t *testing.T) { raws map[string]string want map[string]string // module: a word of its first problem; absent: accepted }{ - {name: "a valid manifest", raws: map[string]string{"a": `{"module":"a","version":"1"}`}}, - {name: "a refused manifest", raws: map[string]string{"a": `{"module":"a","version":"1","provides":[{"name":"amqp"}]}`}, + {name: "a valid manifest", raws: map[string]string{"a": `{"module":"a","version":"1.0"}`}}, + {name: "a refused manifest", raws: map[string]string{"a": `{"module":"a","version":"1.0","provides":[{"name":"amqp"}]}`}, want: map[string]string{"a": `provides "amqp", which is not a provision`}}, - {name: "a provision named as a credential's", raws: map[string]string{"a": `{"module":"a","version":"1","provides":[{"name":"secret-db","scope":"mesh"}]}`}, + {name: "a provision named as a credential's", raws: map[string]string{"a": `{"module":"a","version":"1.0","provides":[{"name":"secret-db","scope":"mesh"}]}`}, want: map[string]string{"a": `secret-`}}, - {name: "an unknown field", raws: map[string]string{"a": `{"module":"a","version":"1","a-field-from-later":1}`}, + {name: "an unknown field", raws: map[string]string{"a": `{"module":"a","version":"1.0","a-field-from-later":1}`}, want: map[string]string{"a": `"a-field-from-later"`}}, // Design 47 G §23, §28: a step's status is kept in the registration and never in a manifest. - {name: "a manifest naming a step's status", raws: map[string]string{"a": `{"module":"a","version":"1","policy":{"lint":"bypass"}}`}, + {name: "a manifest naming a step's status", raws: map[string]string{"a": `{"module":"a","version":"1.0","policy":{"lint":"bypass"}}`}, want: map[string]string{"a": `"policy"`}}, - {name: "a manifest under another name", raws: map[string]string{"b": `{"module":"a","version":"1"}`}, + {name: "a manifest under another name", raws: map[string]string{"b": `{"module":"a","version":"1.0"}`}, want: map[string]string{"b": `of module "a", not "b"`}}, {name: "a seat declared twice", raws: map[string]string{ - "one": `{"module":"one","version":"1","seats":[{"name":"thing","scope":"mesh"}]}`, - "two": `{"module":"two","version":"1","seats":[{"name":"thing","scope":"mesh"}]}`, - "ok": `{"module":"ok","version":"1"}`}, + "one": `{"module":"one","version":"1.0","seats":[{"name":"thing","scope":"mesh"}]}`, + "two": `{"module":"two","version":"1.0","seats":[{"name":"thing","scope":"mesh"}]}`, + "ok": `{"module":"ok","version":"1.0"}`}, want: map[string]string{"one": "already declares", "two": "already declares"}}, // The lead's ruling 5 for A2h: a module that claims a seat and serves its verbs keeps tools of its own beside them // (the enrolment's first-view tools, DLV-M6), and the judge takes it. {name: "a claimed seat's module with tools of its own", raws: map[string]string{ - "def": `{"module":"def","version":"1","seats":[{"name":"enrolment","scope":"mesh","serves":["issue","again"]}]}`, - "enrolment": `{"module":"enrolment","version":"1","claims":[{"name":"enrolment","scope":"mesh","serves":["issue","again"]}],` + + "def": `{"module":"def","version":"1.0","seats":[{"name":"enrolment","scope":"mesh","serves":["issue","again"]}]}`, + "enrolment": `{"module":"enrolment","version":"1.0","claims":[{"name":"enrolment","scope":"mesh","serves":["issue","again"]}],` + `"tools":["enrolment_first_views","enrolment_first_view_again"]}`}}, } { raws := map[string][]byte{} @@ -67,10 +67,10 @@ func TestTheJudge(t *testing.T) { // A field the judge does not know waits in the indexer's judge and stays a refusal in the checker's (clause 3 of the // record on a release's Approve); a renamed field and any other problem are refused by both. waitRaws := map[string][]byte{ - "later": []byte(`{"module":"later","version":"1","later-field":true,"other":1}`), - "renamed": []byte(`{"module":"renamed","version":"1","needs":{"x":"y"}}`), - "bad": []byte(`{"module":"bad","version":"1","provides":[{"name":"amqp"}]}`), - "ok": []byte(`{"module":"ok","version":"1"}`), + "later": []byte(`{"module":"later","version":"1.0","later-field":true,"other":1}`), + "renamed": []byte(`{"module":"renamed","version":"1.0","needs":{"x":"y"}}`), + "bad": []byte(`{"module":"bad","version":"1.0","provides":[{"name":"amqp"}]}`), + "ok": []byte(`{"module":"ok","version":"1.0"}`), } refused, waits, _ := ShelfWaiting(waitRaws, nil, 0, nil, nil) if !reflect.DeepEqual(waits, map[string][]string{"later": {"later-field", "other"}}) || len(refused) != 2 || refused["renamed"] == nil || refused["bad"] == nil { @@ -82,7 +82,7 @@ func TestTheJudge(t *testing.T) { // The private network's manifest, which the mesh provides itself, is accepted (moved from mesh-sdk overlay's test). if raw, err := json.Marshal(overlay.Manifest()); err != nil { t.Fatal(err) - } else if _, problems := Manifest(raw, nil); len(problems) > 0 { + } else if _, problems := Manifest(versioned(t, raw), nil); len(problems) > 0 { t.Errorf("the provided private network is refused: %v", problems) } shelf := catalogue.Shelf{"nats": {}, "mesh-nats": {}, "a.b": {}} @@ -103,8 +103,8 @@ func TestTheJudge(t *testing.T) { // What cmd/mesh-judge answers the checker (ADR 0578 §2): a composer request in, a verdict out, each manifest accepted // or refused; a manifest left as a digest is a request it cannot run. r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "change", Manifests: map[string]composer.Manifest{ - "a": {Raw: json.RawMessage(`{"module":"a","version":"1"}`)}, - "b": {Raw: json.RawMessage(`{"module":"b","version":"1","requires":["amqp"]}`)}, + "a": {Raw: json.RawMessage(`{"module":"a","version":"1.0"}`)}, + "b": {Raw: json.RawMessage(`{"module":"b","version":"1.0","requires":["amqp"]}`)}, }}}} v, err := Request(r) if err != nil || v.V != composer.Version || len(v.Cases) != 1 || v.SDK != Identity() || !v.Cases[0].Manifests["a"].Accepted || @@ -116,13 +116,13 @@ func TestTheJudge(t *testing.T) { // state, which a trunk module uses, is accepted. old := composer.Request{V: composer.Version, Cases: []composer.Case{ {Name: "change", Manifests: map[string]composer.Manifest{ - "user": {Raw: json.RawMessage(`{"module":"user","version":"1","state":["x"]}`)}, - "late": {Raw: json.RawMessage(`{"module":"late","version":"1","logs":[{"name":"l"}]}`)}, - "newer": {Raw: json.RawMessage(`{"module":"newer","version":"1","state":["y"]}`)}, - "mesh-grants": {Raw: json.RawMessage(`{"module":"mesh-grants","version":"1","logs":[{"name":"l"}]}`)}}}, + "user": {Raw: json.RawMessage(`{"module":"user","version":"1.0","state":["x"]}`)}, + "late": {Raw: json.RawMessage(`{"module":"late","version":"1.0","logs":[{"name":"l"}]}`)}, + "newer": {Raw: json.RawMessage(`{"module":"newer","version":"1.0","state":["y"]}`)}, + "mesh-grants": {Raw: json.RawMessage(`{"module":"mesh-grants","version":"1.0","logs":[{"name":"l"}]}`)}}}, {Name: "trunk", Manifests: map[string]composer.Manifest{ - "user": {Raw: json.RawMessage(`{"module":"user","version":"1","state":["x"]}`)}, - "mesh-grants": {Raw: json.RawMessage(`{"module":"mesh-grants","version":"1","logs":[{"name":"l"}]}`)}}}}} + "user": {Raw: json.RawMessage(`{"module":"user","version":"1.0","state":["x"]}`)}, + "mesh-grants": {Raw: json.RawMessage(`{"module":"mesh-grants","version":"1.0","logs":[{"name":"l"}]}`)}}}}} ov, err := Request(old) if err != nil { t.Fatal(err) @@ -137,3 +137,19 @@ func TestTheJudge(t *testing.T) { t.Fatal("a manifest left as a digest was judged") } } + +// versioned is a manifest whose `version` is a MAJOR.MINOR (design 47 G §368): the private network's manifest is +// mesh-networking's, which G's change gives its version. +func versioned(t *testing.T, raw []byte) []byte { + t.Helper() + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + fields["version"] = "1.0" + out, err := json.Marshal(fields) + if err != nil { + t.Fatal(err) + } + return out +} diff --git a/modules/mesh-delivery-indexer/judge/objects_test.go b/modules/mesh-delivery-indexer/judge/objects_test.go index a74bfe5d..36d34ef4 100644 --- a/modules/mesh-delivery-indexer/judge/objects_test.go +++ b/modules/mesh-delivery-indexer/judge/objects_test.go @@ -10,7 +10,7 @@ import ( // knowledge is mesh-knowledge's manifest as ADR 0592 has it: an object bucket granted by its trusted value readers (a // trusted file takes it), and // two streams that keep their history. -const knowledge = `{"module":"mesh-knowledge","version":"1", +const knowledge = `{"module":"mesh-knowledge","version":"1.0", "resources":[{"id":"state","type":"directory","mode":"0700","place":"."}, {"id":"readers","trusted":true,"type":"file","path":"${dir:state}/readers.json","mode":"0600", "merge":"json","takes-settings":["readers"],"content":"{\"readers\": {}}"}], @@ -41,7 +41,7 @@ func TestObjectBucketsAreJudgedAsADR0592Says(t *testing.T) { } } // A bucket whose name on the bus starts with what the mesh derives names with is refused. - if _, err := ParseManifest([]byte(`{"module":"kv","version":"1","objects":[{"name":"x","max-mib":1}]}`)); err == nil || + if _, err := ParseManifest([]byte(`{"module":"kv","version":"1.0","objects":[{"name":"x","max-mib":1}]}`)); err == nil || !strings.Contains(err.Error(), "KV_") { t.Errorf("a bucket kv_x was taken: %v", err) } @@ -54,7 +54,7 @@ func TestObjectBucketsAreJudgedAsADR0592Says(t *testing.T) { return m } owner := parse(knowledge) - reader := parse(`{"module":"records-page","version":"1","reads":["mesh-knowledge.sections"]}`) + reader := parse(`{"module":"records-page","version":"1.0","reads":["mesh-knowledge.sections"]}`) shelf := catalogue.Shelf{"mesh-knowledge": owner, "records-page": reader} if p := objectProblemsOf(CatalogueProblems(shelf)); len(p) > 0 { t.Fatalf("a granted read was refused: %v", p) diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index b2e091a9..1928421c 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -2536,7 +2536,7 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M } m.SetPredefined(predefined) - var problems []string + problems := gWordsProblems(m) 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)) diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 58f17e35..7d9da3e6 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -30,7 +30,7 @@ import ( // 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 caseABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"pg","version":"1", + _, err := ParseManifest([]byte(`{"module":"pg","version":"1.0", "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) @@ -109,7 +109,7 @@ func caseABuildsBundlesAreCarriedOnTheResolvedManifest(t *testing.T) { 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"}]},` + + raw := `{"module":"x","version":"1.0","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) @@ -261,7 +261,7 @@ func caseAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) { // 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"]}]}`) + return []byte(`{"module":"mesh-self-check","version":"1.0","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) @@ -366,10 +366,10 @@ func caseAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) { func caseAContributionLocalNameMustBeUsable(t *testing.T) { for _, bad := range []string{ // Not a usable name. - `{"module":"minio","version":"1","requires":["route"], + `{"module":"minio","version":"1.0","requires":["route"], "contributes":{"route":{"Not OK":{"label":"files","port":9000}}}}`, // A local contribution with nothing in it. - `{"module":"minio","version":"1","requires":["route"], + `{"module":"minio","version":"1.0","requires":["route"], "contributes":{"route":{"api":{}}}}`, } { if _, err := ParseManifest([]byte(bad)); err == nil { @@ -423,20 +423,20 @@ func caseADeclaredStoreParsesAndPassesTheCheck(t *testing.T) { // The node-engine's rules, for directories and — as the engine judges them since novox/hq issue 495 — files. func caseADefinitionIsRefusedWhereTheNodeEngineRefusesItsPaths(t *testing.T) { refused := map[string]string{ - "a stated directory in docker's data": `{"module": "sidecar", "version": "1", "resources": [ + "a stated directory in docker's data": `{"module": "sidecar", "version":"1.0", "resources": [ {"id": "volumes", "type": "directory", "path": "/var/lib/docker/volumes/x"}]}`, - "a file in containerd's data": `{"module": "images", "version": "1", "resources": [ + "a file in containerd's data": `{"module": "images", "version":"1.0", "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": [ + "a file beneath a placed directory that climbs out of it": `{"module": "docker", "version":"1.0", "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": [ + "a file in /boot": `{"module": "grub", "version":"1.0", "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": [ + "a directory that is the mesh's whole tree": `{"module": "mesh", "version":"1.0", "resources": [ {"id": "all", "type": "directory", "place": "."}]}`, - "a directory that holds /etc": `{"module": "x", "version": "1", "resources": [ + "a directory that holds /etc": `{"module": "x", "version":"1.0", "resources": [ {"id": "d", "type": "directory", "path": "/"}]}`, - "a directory in the node-engine's own tree, by another module": `{"module": "intruder", "version": "1", + "a directory in the node-engine's own tree, by another module": `{"module": "intruder", "version":"1.0", "resources": [{"id": "d", "type": "directory", "path": "/var/lib/mesh-host/x"}]}`, } for name, raw := range refused { @@ -450,24 +450,24 @@ func caseADefinitionIsRefusedWhereTheNodeEngineRefusesItsPaths(t *testing.T) { // 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": [ + "a unit in /etc": `{"module": "power", "version":"1.0", "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": [ + "a run directory": `{"module": "fail2ban", "version":"1.0", "resources": [ {"id": "run-dir", "type": "directory", "path": "/var/run/fail2ban"}]}`, - "a program in /usr/local/bin": `{"module": "claude-code", "version": "1", "resources": [ + "a program in /usr/local/bin": `{"module": "claude-code", "version":"1.0", "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": [ + "the node-engine's launcher and state, by the node-engine": `{"module": "mesh-host", "version":"1.0", "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": [ + "an account's .ssh": `{"module": "ssh-client", "version":"1.0", "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": [ + "a module's own root": `{"module": "mailu", "version":"1.0", "resources": [ {"id": "state", "type": "directory", "place": "."}]}`, - "a file below /lib": `{"module": "udev", "version": "1", "resources": [ + "a file below /lib": `{"module": "udev", "version":"1.0", "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": [ + "directories at /storage, /data, /services and /var/lock": `{"module": "roots", "version":"1.0", "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"}]}`, } @@ -508,7 +508,7 @@ func caseADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) { // 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"}]}`) + raw := []byte(`{"module":"thing","version":"1.0","claims":[{"name":"git","scope":"mesh"}]}`) m, err := ParseManifest(raw) if err != nil { t.Fatalf("the parser judged what a seat delivers: %v", err) @@ -544,7 +544,7 @@ func caseADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) { func caseADirectoryPlacedInDockersDataIsRefusedWhereTheDefinitionIsJudged(t *testing.T) { // The #205 shape, as it was merged. - _, err := ParseManifest([]byte(`{"module": "docker", "version": "1", + _, err := ParseManifest([]byte(`{"module": "docker", "version":"1.0", "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") @@ -647,7 +647,7 @@ func caseAGuardedPortMustBeAPort(t *testing.T) { // person (the offer's own secret issued outside). func caseAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) { storeOffering := func(credential string) catalogue.Manifest { - raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + + raw := `{"module":"store","version":"1.0","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"},` + @@ -685,7 +685,7 @@ func caseAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing. 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 + `}]}` + raw := `{"module":"store","version":"1.0","provides":[{"name":"postgres-database","scope":"mesh"` + credential + `}]}` var m catalogue.Manifest _ = json.Unmarshal([]byte(raw), &m) problems := strings.Join(CredentialFormProblems(m), "; ") @@ -722,7 +722,7 @@ func caseAHolderOfNodeFilesDeclaringRunsAsIsRefused(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 caseAHolderOfNodeReadsWithoutAnAccountOfItsOwnIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module": "reads", "version": "1", + _, err := ParseManifest([]byte(`{"module": "reads", "version":"1.0", "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) @@ -872,7 +872,7 @@ func caseAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) { // A local name names one credential: two requirements may not share it (review C2). func caseALocalNameIsUniqueAcrossRequirements(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["secret","postgres-database"], + _, err := ParseManifest([]byte(`{"module":"x","version":"1.0","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) @@ -882,15 +882,15 @@ func caseALocalNameIsUniqueAcrossRequirements(t *testing.T) { func caseALocalNameMayNotCollideWithWhatTheModuleAlreadyCallsSomething(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"}, + `{"module":"ca","version":"1.0","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"], + `{"module":"ca","version":"1.0","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"}}}`, + `{"module":"ca","version":"1.0","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"}}}`, + `{"module":"ca","version":"1.0","requires":["secret"],"secrets":{"secret":{"root-key":"root.key"}}}`, } { if _, err := ParseManifest([]byte(bad)); err == nil { t.Errorf("accepted:\n%s", bad) @@ -902,16 +902,16 @@ func caseALocalNameMayNotCollideWithWhatTheModuleAlreadyCallsSomething(t *testin // field the mesh does not know, or kept by a module whose own name is not one plain token. func caseALogIsNamedLocallyAndCapped(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, form, catch-up}`}, - {`{"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`}, + {`{"module":"a","version":"1.0","logs":["mesh.changes"]}`, `keeps log "mesh.changes": a log is named locally`}, + {`{"module":"a","version":"1.0","logs":["my_changes"]}`, `keeps log "my_changes"`}, + {`{"module":"a","version":"1.0","logs":["Changes"]}`, `keeps log "Changes"`}, + {`{"module":"a","version":"1.0","logs":["c","c"]}`, `keeps log "c" twice`}, + {`{"module":"a","version":"1.0","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`}, + {`{"module":"a","version":"1.0","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`}, + {`{"module":"a","version":"1.0","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`}, + {`{"module":"a","version":"1.0","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days, form, catch-up}`}, + {`{"module":"a","version":"1.0","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650, not 0`}, + {`{"module":"a.b","version":"1.0","logs":["c"]}`, `no dot`}, } { _, err := ParseManifest([]byte(c.manifest)) if c.says == "" { @@ -1043,7 +1043,7 @@ func caseAMalformedPromptIsRefused(t *testing.T) { "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":{ + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1.0","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) { @@ -1126,11 +1126,11 @@ func caseAManifestKeyNothingReadsIsRefused(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 caseAManifestMayListenFromTheLocalNetwork(t *testing.T) { - if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", + if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1.0", "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", + _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1.0", "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) @@ -1154,7 +1154,7 @@ func caseAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) { } func caseAManifestProvidingAmqpIsRefused(t *testing.T) { - raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`) + raw := []byte(`{"module":"old-broker","version":"1.0","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) @@ -1162,7 +1162,7 @@ func caseAManifestProvidingAmqpIsRefused(t *testing.T) { } func caseAManifestRequiringAmqpIsRefused(t *testing.T) { - raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`) + raw := []byte(`{"module":"forwarder","version":"1.0","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) @@ -1177,7 +1177,7 @@ func caseAManifestRequiringAmqpIsRefused(t *testing.T) { // and the mesh knows what it is called now. func caseAManifestUsingTheOldNameIsToldTheNewOne(t *testing.T) { _, err := ParseManifest([]byte( - `{"module":"postgres","version":"1","needs":{"superuser":"/var/lib/superuser"}}`)) + `{"module":"postgres","version":"1.0","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") } @@ -1192,7 +1192,7 @@ func caseAManifestUsingTheOldNameIsToldTheNewOne(t *testing.T) { // groups count as a place for it); a placeholder in any other shape is refused where the manifest is read. func caseAManifestWhoseContainerTakesItsGroupFromConfigRegisters(t *testing.T) { m := map[string]any{ - "module": "n8n", "version": "1", + "module": "n8n", "version": "1.0", "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}")}, @@ -1209,7 +1209,7 @@ func caseAManifestWhoseContainerTakesItsGroupFromConfigRegisters(t *testing.T) { } func caseAManifestWithoutUnhealthyIsNotUrgent(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"notes","version":"1"}`)) + m, err := ParseManifest([]byte(`{"module":"notes","version":"1.0"}`)) if err != nil { t.Fatal(err) } @@ -1237,7 +1237,7 @@ func caseAMapReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) { // 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 caseAMisspelledPlaceholderIsRefusedAtTheCheck(t *testing.T) { - raw := `{"module":"speller","version":"1","resources":[{"id":"rc","type":"file","path":"/etc/speller.rc",` + + raw := `{"module":"speller","version":"1.0","resources":[{"id":"rc","type":"file","path":"/etc/speller.rc",` + `"content":"a=` + misspelledShell + `\nb=` + undeclaredSetting + `\nc=` + misspelledMachine + `\nd=` + shellsOwn + `\n"}]}` _, err := ParseManifest([]byte(raw)) @@ -1263,7 +1263,7 @@ func caseAMisspelledPlaceholderIsRefusedAtTheCheck(t *testing.T) { {`{"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 + `]}` + raw := `{"module":"speller","version":"1.0","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) @@ -1272,7 +1272,7 @@ func caseAMisspelledPlaceholderIsRefusedAtTheCheck(t *testing.T) { // 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",` + + raw = `{"module":"speller","version":"1.0","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}"}}]}` @@ -1303,7 +1303,7 @@ func caseAModuleDeclaresItsOwnSeatAndHoldsIt(t *testing.T) { // 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 caseAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) { - ok := []byte(`{"module":"showcase","version":"1","seats":[{"name":"the-showcase","scope":"node"}],` + + ok := []byte(`{"module":"showcase","version":"1.0","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) @@ -1320,7 +1320,7 @@ func caseAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) { 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"}],` + + bad := []byte(`{"module":"x","version":"1.0","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) @@ -1329,7 +1329,7 @@ func caseAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) { func caseAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) { // Otherwise nobody could say where a given file on a machine came from. - _, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1", + _, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1.0", "computed":"mesh-network", "resources":[{"id":"a","type":"package","package":"wireguard-tools"}]}`)) if err == nil { @@ -1348,7 +1348,7 @@ func caseAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) { // 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 caseAModuleMayNotDeclareAnAction(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[ + _, err := ParseManifest([]byte(`{"module":"probe","version":"1.0","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") @@ -1370,7 +1370,7 @@ func caseAModuleMayNotDeclareAnAction(t *testing.T) { // provider of each name, so no mismatch was expressible and none was caught. func caseAModuleMayNotProvideAGenericDatabase(t *testing.T) { for _, hidden := range []string{"database", "db", "sql", "sql-database"} { - _, err := ParseManifest([]byte(`{"module":"postgres","version":"1", + _, err := ParseManifest([]byte(`{"module":"postgres","version":"1.0", "provides":[{"name":"` + hidden + `","scope":"mesh"}]}`)) if err == nil { t.Fatalf("providing %q was accepted; a requirement for it matches any engine", hidden) @@ -1411,7 +1411,7 @@ func caseAModuleOffersItsOwnStateOnly(t *testing.T) { // A module says what its own tools replace, and only for a tool it declares. func caseAModuleSaysWhatItsOwnToolsReplace(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"docker","version":"1","tools":["docker_logs","docker_list"],` + + m, err := ParseManifest([]byte(`{"module":"docker","version":"1.0","tools":["docker_logs","docker_list"],` + `"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}}`)) if err != nil { t.Fatal(err) @@ -1420,15 +1420,15 @@ func caseAModuleSaysWhatItsOwnToolsReplace(t *testing.T) { 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"]}}`, + {`{"module":"docker","version":"1.0","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 /"]}}`, + {`{"module":"docker","version":"1.0","tools":["docker_logs"],"replaces":{"docker_logs":[]}}`, "replaces nothing"}, + {`{"module":"docker","version":"1.0","tools":["docker_logs"],"replaces":{"docker_logs":[" "]}}`, "empty command"}, + {`{"module":"docker","version":"1.0","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"]}}`, + {`{"module":"docker","version":"1.0","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs","docker logs"]}}`, "twice"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["` + strings.Repeat("x", catalogue.MaxReplaced+1) + `"]}}`, + {`{"module":"docker","version":"1.0","tools":["docker_logs"],"replaces":{"docker_logs":["` + strings.Repeat("x", catalogue.MaxReplaced+1) + `"]}}`, "longer than"}, } { if _, err := ParseManifest([]byte(c.manifest)); err == nil || !strings.Contains(err.Error(), c.says) { @@ -1497,7 +1497,7 @@ func caseAModuleThatDeclaresASettingNamesItsFilesKeys(t *testing.T) { 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, + raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1.0", "resources": m.Resources, "settings": map[string]any{"workers": map[string]any{"kind": catalogue.KindPreference, "default": 4, "why": "a number"}}}) if err != nil { t.Fatal(err) @@ -1553,13 +1553,13 @@ func caseAMountNothingDeclaresIsRefused(t *testing.T) { func caseAMovedModulesMarkIsJudged(t *testing.T) { for name, raw := range map[string]string{ - "marked moved and naming no template": `{"module":"bare","version":"1","moved":true, + "marked moved and naming no template": `{"module":"bare","version":"1.0","moved":true, "resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`, - "handed written in a repository": `{"module":"bare","version":"1","moved":true, + "handed written in a repository": `{"module":"bare","version":"1.0","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 and not moved": `{"module":"bare","version":"1.0", "handed":{"source":"s","digest":"d","templates":{}}, "resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`, } { @@ -1616,7 +1616,7 @@ func caseAnArtifactBuiltFromOutsideItsRepositoryIsRefused(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":[ + _, err := ParseManifest([]byte(`{"module":"a","version":"1.0","build":{"artifacts":[ {"name":"x","kind":"archive","from":"` + from + `"}]}}`)) if err == nil { t.Fatalf("%q was accepted as a build input", from) @@ -1632,7 +1632,7 @@ func caseAnArtifactBuiltFromOutsideItsRepositoryIsRefused(t *testing.T) { // refusal names the artifact and its kind. Each path stays inside the module's directory: no `..`, no absolute path, // no pattern. func caseAnArtifactNamesThePathsItIsBuiltFrom(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk","build":{"artifacts":[ + m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1.0","slug":"sdk","build":{"artifacts":[ {"name":"lib","kind":"package","language":"typescript", "paths":["src","test","package.json","package-lock.json","tsconfig.json"]}]},"resources":[]}`)) if err != nil { @@ -1641,7 +1641,7 @@ func caseAnArtifactNamesThePathsItIsBuiltFrom(t *testing.T) { 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":[ + if _, err := ParseManifest([]byte(`{"module":"a","version":"1.0","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) } @@ -1660,7 +1660,7 @@ func caseAnArtifactNamesThePathsItIsBuiltFrom(t *testing.T) { {`{"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 + `]}}`)) + _, err := ParseManifest([]byte(`{"module":"a","version":"1.0","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) } @@ -1668,7 +1668,7 @@ func caseAnArtifactNamesThePathsItIsBuiltFrom(t *testing.T) { } func caseAnArtifactOfAnUnknownKindIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + _, err := ParseManifest([]byte(`{"module":"a","version":"1.0","build":{"artifacts":[ {"name":"x","kind":"binary","from":"main.go"}]}}`)) if err == nil { t.Fatal("an artifact of an unknown kind was accepted") @@ -1676,7 +1676,7 @@ func caseAnArtifactOfAnUnknownKindIsRefused(t *testing.T) { } func caseANeedIsAnAbsolutePath(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"postgres","version":"1", + _, err := ParseManifest([]byte(`{"module":"postgres","version":"1.0", "own-secrets":{"superuser":"superuser.txt"}}`)) if err == nil { t.Fatal("a relative path was accepted") @@ -1714,7 +1714,7 @@ func caseANeedMustNameANodeSeatSomewhereInTheCatalogue(t *testing.T) { func caseAnEmptyContributionIsRefused(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", + _, err := ParseManifest([]byte(`{"module":"board","version":"1.0", "contributes":{"reverse-proxy":{}}}`)) if err == nil { t.Fatal("a module contributed nothing and was accepted") @@ -1804,7 +1804,7 @@ func caseAnInterpretedBundleStillMayNotNameASource(t *testing.T) { // And an ordinary unknown key is still refused as one, rather than being guessed at. func caseAnInventedKeyIsNotTreatedAsARename(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"postgres","version":"1","nonsense":{}}`)) + _, err := ParseManifest([]byte(`{"module":"postgres","version":"1.0","nonsense":{}}`)) if err == nil { t.Fatal("an invented key was accepted") } @@ -1816,7 +1816,7 @@ func caseAnInventedKeyIsNotTreatedAsARename(t *testing.T) { // 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 caseAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`)) + _, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1.0","invokes":["shop"]}`)) if err == nil { t.Fatal("an invoke naming no tool was accepted") } @@ -1829,7 +1829,7 @@ func caseAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) { // 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 caseAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"downloader","version":"1", + m, err := ParseManifest([]byte(`{"module":"downloader","version":"1.0", "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"}}}`)) @@ -1844,10 +1844,10 @@ func caseAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) { } 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"}, + "declares no such secret": `{"module":"d","version":"1.0","provides":[{"name":"d-api","credential":{"own":"password"}}]}`, + "must say how the module takes it": `{"module":"d","version":"1.0","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":""}}]}`, + "names no own secret": `{"module":"d","version":"1.0","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) @@ -1859,7 +1859,7 @@ func caseAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) { // 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 caseAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"tv","version":"1", + m, err := ParseManifest([]byte(`{"module":"tv","version":"1.0", "provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}], "serves":{"tv-api":{"port":8989}}}`)) if err != nil { @@ -1879,11 +1879,11 @@ func caseAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) { } for want, raw := range map[string]string{ - "names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}}, + "names its own secret": `{"module":"d","version":"1.0","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", + "names the tool": `{"module":"d","version":"1.0", "provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`, - "the issuers an offer may name": `{"module":"d","version":"1", + "the issuers an offer may name": `{"module":"d","version":"1.0", "provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`, } { if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { @@ -1895,7 +1895,7 @@ func caseAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) { // 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 caseAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"media","version":"1", + m, err := ParseManifest([]byte(`{"module":"media","version":"1.0", "provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}], "serves":{"media-api":{"port":32400}}}`)) if err != nil { @@ -1912,13 +1912,13 @@ func caseAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) { } for want, raw := range map[string]string{ - "the issuers an offer may name": `{"module":"d","version":"1", + "the issuers an offer may name": `{"module":"d","version":"1.0", "provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`, - "names no tool that answers it": `{"module":"d","version":"1", + "names no tool that answers it": `{"module":"d","version":"1.0", "provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`, - "names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}}, + "names its own secret": `{"module":"d","version":"1.0","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", + "the issuers an offer may name ": `{"module":"d","version":"1.0", "provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`, } { want = strings.TrimSpace(want) @@ -2001,7 +2001,7 @@ func caseAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(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 caseAnOwnerSettingAskedOnlyInATemplateIsJudgedOnceFilled(t *testing.T) { - raw := `{"module":"sonarr","version":"1", + raw := `{"module":"sonarr","version":"1.0", "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"}]}` @@ -2077,7 +2077,7 @@ func caseAnOwnerSettingIsJudgedWhereTheManifestIsRead(t *testing.T) { // 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 caseAnOwnSecretSaysWhenAnOutsidePartyIssuesIt(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ + m, err := ParseManifest([]byte(`{"module":"letta","version":"1.0","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"}, @@ -2106,7 +2106,7 @@ func caseAnOwnSecretSaysWhenAnOutsidePartyIssuesIt(t *testing.T) { 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":{ + _, err = ParseManifest([]byte(`{"module":"letta","version":"1.0","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) @@ -2121,7 +2121,7 @@ func caseAnOwnSecretsOwnerIsRefusedWhenItIsNotAnAccountTheModuleMakes(t *testing "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":{` + + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1.0","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) { @@ -2131,12 +2131,12 @@ func caseAnOwnSecretsOwnerIsRefusedWhenItIsNotAnAccountTheModuleMakes(t *testing } func caseAnOwnSecretTakenSomeOtherWayIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ + _, err := ParseManifest([]byte(`{"module":"idp","version":"1.0","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":{ + _, err = ParseManifest([]byte(`{"module":"idp","version":"1.0","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") @@ -2161,7 +2161,7 @@ func caseAnUncleanSpellingOfASeatsPathIsTheSeatsPath(t *testing.T) { } func caseAnUnhealthySeverityOtherThanUrgentOrWarningIsRefused(t *testing.T) { - if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"loud","why":"mail stops"}}`)); err == nil || + if _, err := ParseManifest([]byte(`{"module":"mail","version":"1.0","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) } @@ -2173,7 +2173,7 @@ func caseAnUnhealthyWithoutWhyIsRefused(t *testing.T) { t.Errorf("urgent with why %q was accepted", why) } } - if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"urgent"}}`)); err == nil || + if _, err := ParseManifest([]byte(`{"module":"mail","version":"1.0","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) } @@ -2182,7 +2182,7 @@ func caseAnUnhealthyWithoutWhyIsRefused(t *testing.T) { // A reference to an access the definition does not declare is refused where the author is. func caseAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) { _, err := ParseManifest([]byte(`{ - "module": "arr", "version": "1", + "module": "arr", "version":"1.0", "accesses": [{"id": "series", "mode": "read-write"}], "resources": [ {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, @@ -2192,7 +2192,7 @@ func caseAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) { 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"}]}`)) + _, err = ParseManifest([]byte(`{"module": "arr", "version":"1.0", "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) } @@ -2237,7 +2237,7 @@ func caseAnUpstreamFileFromAnAddressOrAReservedNameIsRefused(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"}`))) + pinnedSum + `","version":"1.0"}`))) if err == nil || !strings.Contains(err.Error(), "published under a public name") { t.Errorf("%s: %v", u, err) } @@ -2272,27 +2272,27 @@ func caseAnUpstreamFilesVendorIsNamedOnPurposeAndAnInstallationsIsRefused(t *tes func caseAnUpstreamFileThatIsNotPinnedIsRefused(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", + `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","version":"1.0"}`: "without a pinned sha256", + `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"ABC","version":"1.0"}`: "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", + `{"name":"x","kind":"archive","url":"http://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `","version":"1.0"}`: "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", + `","version":"1.0"}`: "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", + `","version":"1.0"}`: "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", + `","version":"1.0","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", + `","version":"1.0","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", + `","version":"1.0","within":"r","file":"r.tar.xz"}`: "a file is kept whole", } for artifact, want := range cases { _, err := ParseManifest([]byte(upstreamManifest(artifact))) @@ -2308,7 +2308,7 @@ func caseAnUpstreamFileThatIsNotPinnedIsRefused(t *testing.T) { func caseAPackageParsesLikeABundleAndNeedsALanguage(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", + m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1.0","slug":"sdk", "build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]}, "resources":[]}`)) if err != nil { @@ -2320,12 +2320,12 @@ func caseAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) { // 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":[ + if _, err := ParseManifest([]byte(`{"module":"a","version":"1.0","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":[ + if _, err := ParseManifest([]byte(`{"module":"a","version":"1.0","build":{"artifacts":[ {"name":"lib","kind":"package"}]}}`)); err == nil { t.Fatal("a package with no language was accepted") } @@ -2346,9 +2346,9 @@ func caseAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) { {`{"name":"go","kind":"package","language":"go","paths":["go"],"generated-from":"."}` + claims, `a contracts module, which holds contracts only and claims no seat; it claims mesh-vault`}, } { - raw := `{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}` + raw := `{"module":"a","version":"1.0","build":{"artifacts":[` + c.artifact + `]}}` if before, ok := strings.CutSuffix(c.artifact, claims); ok { - raw = `{"module":"a","version":"1","claims":[{"name":"mesh-vault","scope":"mesh"}],"build":{"artifacts":[` + before + `]}}` + raw = `{"module":"a","version":"1.0","claims":[{"name":"mesh-vault","scope":"mesh"}],"build":{"artifacts":[` + before + `]}}` } _, err := ParseManifest([]byte(raw)) switch { @@ -2362,7 +2362,7 @@ func caseAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) { func caseAPathThroughAnAccessIsJudgedWithTheAccessFilledIn(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", + _, err := ParseManifest([]byte(`{"module": "backup", "version":"1.0", "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") || @@ -2371,7 +2371,7 @@ func caseAPathThroughAnAccessIsJudgedWithTheAccessFilledIn(t *testing.T) { } // 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", + if _, err := ParseManifest([]byte(`{"module": "backup", "version":"1.0", "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) @@ -2427,7 +2427,7 @@ func caseAProcessBusAccountMustBeTheModulesOwn(t *testing.T) { "user root": {"root", "root"}, "the operator account": {"${machine:account}", "operator"}, } { - raw := `{"module":"mesh-mail","version":"1","emits":["mail.send.sent"],"state":["sends"],` + + raw := `{"module":"mesh-mail","version":"1.0","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"]` @@ -2499,7 +2499,7 @@ func caseAProcessDeclaresItsBusAndIsAccepted(t *testing.T) { // 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",` + + return `{"module":"nats","version":"1.0","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",` + @@ -2553,7 +2553,7 @@ func caseAProcessDeclaresItsBusAndIsAccepted(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 caseAPromptHoldingMemberOnASingleSecretIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1.0","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") { @@ -2625,7 +2625,7 @@ func caseARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) { func caseARenamedEntryIsTheConfigFormsOnly(t *testing.T) { m := map[string]any{ - "module": "notifier", "version": "1", + "module": "notifier", "version": "1.0", "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"}}}, @@ -2657,7 +2657,7 @@ func caseARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) { // as naming an installation. Archive resources are the same kind of reference and get the same. func caseAResolvedReferenceIsKeptNotRouted(t *testing.T) { m, err := ParseManifest([]byte(`{ - "module": "sample", "version": "1", + "module": "sample", "version":"1.0", "tools": ["one"], "build": {"artifacts": [ {"name": "code", "kind": "bundle", "language": "typescript", "entrypoints": ["tools/index.js"]}, @@ -2882,7 +2882,7 @@ func caseASiteScopedProvisionIsRefused(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"}]}`)) + `{"module":"dns","version":"1.0","provides":[{"name":"resolver","scope":"site"}]}`)) if err == nil { t.Fatal("a site-scoped provision was accepted") } @@ -2918,18 +2918,18 @@ func caseASourceNobodyDefinedIsRefused(t *testing.T) { // registration in the manifest's words. func caseAStateNameIsLocalAndOneToken(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 0 or 1 to 64 (0 is taken as 1)`}, - {`{"module":"a","version":"1","state":[{"name":"s","history":0}]}`, ``}, // 0 passes, as before (ADR 0579 §1) + {`{"module":"a","version":"1.0","state":["mesh.servers"]}`, `keeps state "mesh.servers": a state is named locally`}, + {`{"module":"a","version":"1.0","state":["my_servers"]}`, `keeps state "my_servers"`}, + {`{"module":"a","version":"1.0","state":["s","s"]}`, `keeps state "s" twice`}, + {`{"module":"a","version":"1.0","state":[{"name":"s","history":65}]}`, `a key keeps 0 or 1 to 64 (0 is taken as 1)`}, + {`{"module":"a","version":"1.0","state":[{"name":"s","history":0}]}`, ``}, // 0 passes, as before (ADR 0579 §1) // 1966/AC9: one name, one form - {`{"module":"a","version":"1","state":["x"],"requires":[{"name":"x","storage":"key-value","template":"state"}]}`, + {`{"module":"a","version":"1.0","state":["x"],"requires":[{"name":"x","storage":"key-value","template":"state"}]}`, `a's bus state "x": the name is also kept in the old section state`}, - {`{"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, read-only, form, catch-up}`}, + {`{"module":"a.b","version":"1.0","state":["s"]}`, `no dot`}, + {`{"module":"a","version":"1.0","reads":["bindings"]}`, `a read is .`}, + {`{"module":"a","version":"1.0","reads":["a.s"]}`, `which is its own state`}, + {`{"module":"a","version":"1.0","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine, key-ttl, read-only, form, catch-up}`}, } { _, err := ParseManifest([]byte(c.manifest)) if c.says == "" { @@ -3014,7 +3014,7 @@ func caseAStreamIsNamedBoundedAndGrantedByATrustedConfigValue(t *testing.T) { {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`}, + {`{"module":"a.b","version":"1.0","streams":[{"name":"x","events":["x"]}]}`, `no dot`}, } { _, err := ParseManifest([]byte(c.manifest)) if err == nil { @@ -3091,7 +3091,7 @@ func caseATemplatesDirectoryNoArchiveIsBuiltFromIsRefused(t *testing.T) { } func caseATemplatesDirectoryOnAModuleNamingNoTemplateIsRefused(t *testing.T) { - raw := strings.Replace(unpackingInline, `"version":"1",`, `"version":"1","templates":"templates",`, 1) + raw := strings.Replace(unpackingInline, `"version":"1.0",`, `"version":"1.0","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) } @@ -3135,7 +3135,7 @@ func caseATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) { // A misspelt key inside a verb's definition is refused, like one anywhere else in the manifest. func caseAVerbWithAnUnknownKeyIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"till","version":"1",` + + _, err := ParseManifest([]byte(`{"module":"till","version":"1.0",` + `"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) @@ -3146,16 +3146,16 @@ func caseAVerbWithAnUnknownKeyIsRefused(t *testing.T) { // each refusal is said in the manifest's words, at parse, rather than at the composition of the bus's users. func caseAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) { for manifest, want := range map[string]string{ - `{"module":"shop","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", - `{"module":"node-tools","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", // its name says nothing (issue 774) - `{"module":"node-tools","version":"1","tool-runner":true,"waits":["mesh-knowledge.*"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","tool-runner":true,"waits":["**"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","tool-runner":true,"waits":["mesh-knowledge.>"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","tool-runner":true,"waits":["*.reviewed"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","tool-runner":true,"waits":["reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","tool-runner":true,"waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","tool-runner":true,"waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","tool-runner":true,"waits":["a.b","a.b"]}`: "twice", + `{"module":"shop","version":"1.0","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", + `{"module":"node-tools","version":"1.0","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", // its name says nothing (issue 774) + `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.*"]}`: "never a wildcard", + `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["**"]}`: "never a wildcard", + `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.>"]}`: "never a wildcard", + `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["*.reviewed"]}`: "never a wildcard", + `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["reviewed"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["a.b","a.b"]}`: "twice", } { _, err := ParseManifest([]byte(manifest)) if err == nil { @@ -3230,13 +3230,13 @@ func caseAWorkQueueStreamsValuesAreBounded(t *testing.T) { // A zone names the listen that answers it, or there is nothing to forward to (novox/hq ADR 0199). func caseAZoneMustNameOneOfTheModulesListens(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"lab","version":"1", + _, err := ParseManifest([]byte(`{"module":"lab","version":"1.0", "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", + if _, err := ParseManifest([]byte(`{"module":"lab","version":"1.0", "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) @@ -3244,7 +3244,7 @@ func caseAZoneMustNameOneOfTheModulesListens(t *testing.T) { } func caseBindingWhatYouDoNotRequireIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"app","version":"1", + _, err := ParseManifest([]byte(`{"module":"app","version":"1.0", "binds":{"postgres-database":"/etc/app/db.json"}}`)) if err == nil { t.Fatal("a module was told about something it never asked for") @@ -3299,27 +3299,27 @@ func caseCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) { func caseCollectionsAndItemsAreJudged(t *testing.T) { for name, c := range map[string]struct{ raw, says string }{ - "an item of a module not moved": {`{"module":"m","version":"1", + "an item of a module not moved": {`{"module":"m","version":"1.0", "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, + "two items to one collection": {`{"module":"m","version":"1.0","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, + "an item to no slot": {`{"module":"m","version":"1.0","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, + "an item outside the templates": {`{"module":"m","version":"1.0","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", + "an item naming a secret": {`{"module":"m","version":"1.0", "items":[{"collection":"a","content":"t=${secret:token}"}]}`, "where no secret is"}, - "a collection not declared": {`{"module":"m","version":"1", + "a collection not declared": {`{"module":"m","version":"1.0", "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"}], + "an old namespace declared": {`{"module":"m","version":"1.0","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"}], + "a collection placed nowhere": {`{"module":"m","version":"1.0","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"}], + "a collection in a path": {`{"module":"m","version":"1.0","collections":[{"name":"a"}], "resources":[{"id":"f","type":"file","path":"/${collected:a}","content":"${collected:a}"}]}`, "only in a file's content"}, } { @@ -3523,7 +3523,7 @@ func caseConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) { // 1658/AC2: a declared value placed nowhere, with an item that does not say it, is still refused. func caseConfigStillRefusesAValueNoItemPlaces(t *testing.T) { m := map[string]any{ - "module": "m", "version": "1", + "module": "m", "version": "1.0", "config": map[string]any{ "k": map[string]any{"type": "string", "description": "a word nothing says", "default": "x"}, }, @@ -3535,7 +3535,7 @@ func caseConfigStillRefusesAValueNoItemPlaces(t *testing.T) { // 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 caseConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) { - idp := mustParse(t, `{"module":"idp","version":"1","requires":["postgres-database"], + idp := mustParse(t, `{"module":"idp","version":"1.0","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"}}}}`) @@ -3807,10 +3807,10 @@ func caseIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly(t *testing // 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 caseIrreplaceableDataKeptWithAProviderMustBeProtectedThere(t *testing.T) { - photos := mustParse(t, `{"module":"photos","version":"1","requires":["s3-bucket"], + photos := mustParse(t, `{"module":"photos","version":"1.0","requires":["s3-bucket"], "data":{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}}`) store := func(backup string) catalogue.Manifest { - return mustParse(t, `{"module":"objects","version":"1","provides":[{"name":"s3-bucket","scope":"mesh"}], + return mustParse(t, `{"module":"objects","version":"1.0","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"}}}, @@ -3852,11 +3852,11 @@ func caseItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) { } func caseKeepsMustBeAnAbsolutePath(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"root"}`)) + _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1.0","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 { + if _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1.0","keeps":"/var/lib/mesh-vault/root"}`)); err != nil { t.Fatalf("an absolute keeps path was refused: %v", err) } } @@ -3907,7 +3907,7 @@ func caseNodeBuildAgentIsRetiredForTheBuildSeats(t *testing.T) { t.Errorf("the mesh still defines %s", gone) } } - _, err := ParseManifest([]byte(`{"module":"build-agent","version":"1","claims":[{"name":"node-build-agent","scope":"node"}]}`)) + _, err := ParseManifest([]byte(`{"module":"build-agent","version":"1.0","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) } @@ -4001,7 +4001,7 @@ func caseOneNamingFileMakesEveryMergeableFileName(t *testing.T) { if len(problems) != 1 || !strings.Contains(problems[0], "sidecar merges and names no takes-settings") { t.Fatalf("problems: %v", problems) } - raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources}) + raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1.0", "resources": m.Resources}) if err != nil { t.Fatal(err) } @@ -4042,7 +4042,7 @@ func caseOnlyAClaimantDeclaresASeatsPath(t *testing.T) { // 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 caseOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + m, err := ParseManifest([]byte(`{"module":"a","version":"1.0","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) @@ -4053,7 +4053,7 @@ func caseOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) { {`{"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 + `]}}`)) + _, err := ParseManifest([]byte(`{"module":"a","version":"1.0","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) } @@ -4121,7 +4121,7 @@ func casePlaceIsValidatedAtTheManifest(t *testing.T) { func caseReceivingWhatYouDoNotProvideIsRefused(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", + _, err := ParseManifest([]byte(`{"module":"traefik","version":"1.0", "receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`)) if err == nil { t.Fatal("a module received contributions to something it does not provide") @@ -4193,7 +4193,7 @@ func caseRuntimeHealthIsRefusedWithoutAnAccountOfItsOwn(t *testing.T) { } func caseServingWhatYouDoNotProvideIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"app","version":"1", + _, err := ParseManifest([]byte(`{"module":"app","version":"1.0", "serves":{"postgres-database":{"port":5432}}}`)) if err == nil { t.Fatal("a module served something it does not provide") @@ -4246,7 +4246,7 @@ func caseTakesSettingsIsRefusedWhereItCannotMeanAnything(t *testing.T) { // circle the rule of novox/hq 04-ISSUES/029 refused, a holder whose build publishes through itself, is caught where a // fresh mesh starts (issue 1152), not here. func caseAStoreHolderThatBuildsIsNotRefusedByItsManifest(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"registry","version":"1",` + + _, err := ParseManifest([]byte(`{"module":"registry","version":"1.0",` + `"provides":[{"name":"artifact-store","scope":"mesh"}],` + `"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"registry:2"}]},` + `"resources":[{"id":"store","type":"container","name":"mesh-registry",` + @@ -4272,7 +4272,7 @@ func caseAStoreHolderThatBuildsIsNotRefusedByItsManifest(t *testing.T) { if got := strings.Join(CatalogueProblems(catalogue.Shelf{"registry": holder}), "; "); strings.Contains(got, "artifact-store") { t.Errorf("the store's holder providing nothing was refused: %s", got) } - if _, err := ParseManifest([]byte(`{"module":"old","version":"1","serves":{"artifact-store":{"port":5000}}}`)); err == nil || + if _, err := ParseManifest([]byte(`{"module":"old","version":"1.0","serves":{"artifact-store":{"port":5000}}}`)); err == nil || !strings.Contains(err.Error(), `serves "artifact-store" to whoever requires it, and does not provide it`) { t.Errorf("a module serving artifact-store it does not provide: %v", err) } @@ -4282,7 +4282,7 @@ func caseAStoreHolderThatBuildsIsNotRefusedByItsManifest(t *testing.T) { // 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 caseTheBussAccountSaysNothingOnTheBus(t *testing.T) { - raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + raw := []byte(`{"module":"bus","version":"1.0","provides":[{"name":"mesh-bus","scope":"mesh"}], "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, "emits":["something.happened"]}`) _, err := ParseManifest(raw) @@ -4290,7 +4290,7 @@ func caseTheBussAccountSaysNothingOnTheBus(t *testing.T) { t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err) } // Nor what it invokes, with no bundle for the runtime to ask on its behalf. - unbundledInvoke := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + unbundledInvoke := []byte(`{"module":"bus","version":"1.0","provides":[{"name":"mesh-bus","scope":"mesh"}], "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, "invokes":["seat:mesh-hand-acts.hand-act"]}`) if _, err := ParseManifest(unbundledInvoke); err == nil || !strings.Contains(err.Error(), "its invokes would be granted nothing") { @@ -4301,7 +4301,7 @@ func caseTheBussAccountSaysNothingOnTheBus(t *testing.T) { // node's runtime, which is granted every tool and seat verb (mesh-sdk go/buscompose), so a bus module that // ships one may declare them. The shape of mesh-catalog's modules/nats/module.json at fc5b849d, cut to the // parts the rule reads, was refused and held every bus user list from 2026-11-02 01:35. - nats := []byte(`{"module":"nats","version":"1","provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}], + nats := []byte(`{"module":"nats","version":"1.0","provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}], "claims":[{"name":"mesh-broker","scope":"mesh"}], "own-secrets":{"broker":"/run/broker","broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"}, "resources":[{"id":"tools-secret","type":"directory","path":"/var/lib/mesh/nats-tools","mode":"0711","unknown":"program"}], @@ -4327,7 +4327,7 @@ func caseTheBussAccountSaysNothingOnTheBus(t *testing.T) { // 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"}], + bundled := []byte(`{"module":"bus","version":"1.0","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"}]}}`) @@ -4353,7 +4353,7 @@ func caseTheBussAccountSaysNothingOnTheBus(t *testing.T) { // sections are the keeper's state and the bus's own stream. The shape of A5's nats manifest (issue 1966), cut to the // parts the rule reads. keeper := func(bus string, more string) []byte { - return []byte(`{"module":"nats","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + return []byte(`{"module":"nats","version":"1.0","provides":[{"name":"mesh-bus","scope":"mesh"}], "claims":[{"name":"mesh-broker","scope":"mesh","serves":["list"]}],"own-secrets":{"broker":"/run/broker"}, "tools":["nats_server"],"consumes":["mesh-catalog.manifest-accepted"],"uses":["operator-channel"], "state":[{"name":"requests","history":5}],"streams":[{"name":"broker-events","events":["created"]}], @@ -4391,13 +4391,13 @@ func caseTheBussAccountSaysNothingOnTheBus(t *testing.T) { if _, err := ParseManifest(reads); err == nil || !strings.Contains(err.Error(), "its reads would be granted nothing") { t.Errorf("a bus module reading another's state: %v", err) } - other := []byte(`{"module":"app","version":"1","resources":[{"id":"u","type":"user","name":"app"}, + other := []byte(`{"module":"app","version":"1.0","resources":[{"id":"u","type":"user","name":"app"}, {"id":"p","type":"process","user":"app","run":["./a"],"bus":{"keeps":true}}]}`) if _, err := ParseManifest(other); err == nil || !strings.Contains(err.Error(), "only the keeper") { t.Errorf("keeps on another module's process: %v", err) } - quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + quiet := []byte(`{"module":"bus","version":"1.0","provides":[{"name":"mesh-bus","scope":"mesh"}], "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, "tools":["bus_streams"]}`) m, err = ParseManifest(quiet) @@ -4412,12 +4412,12 @@ func caseTheBussAccountSaysNothingOnTheBus(t *testing.T) { // 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 caseTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) { - unsaid := mustParse(t, `{"module":"q","version":"1","provides":[{"name":"queue","scope":"mesh"}], + unsaid := mustParse(t, `{"module":"q","version":"1.0","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"}], + onTheOffer := mustParse(t, `{"module":"r","version":"1.0","provides":[{"name":"resolver","scope":"mesh","keeps-consumer-data":false}]}`) + byHand := mustParse(t, `{"module":"h","version":"1.0","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"}, + writes := mustParse(t, `{"module":"w","version":"1.0","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(catalogue.Shelf{"q": unsaid, "r": onTheOffer, "h": byHand, "w": writes}), "\n") @@ -4435,7 +4435,7 @@ func caseTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) { 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"}]}, + declared := mustParse(t, `{"module":"w","version":"1.0","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(catalogue.Shelf{"w": declared}); len(p) > 0 { @@ -4603,7 +4603,7 @@ func caseTheDataSectionIsRefusedWhereItIsWrong(t *testing.T) { {"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 + `}` + raw := `{"module":"m","version":"1.0","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) @@ -4749,7 +4749,7 @@ func caseTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) { // A store whose bus seat delivers something this module does provide. catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "test"}}) - raw := []byte(`{"module":"a-bus","version":"1",` + + raw := []byte(`{"module":"a-bus","version":"1.0",` + `"provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}],` + `"claims":[{"name":"mesh-broker","scope":"mesh"}]}`) @@ -4979,7 +4979,7 @@ func caseTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) { // `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 caseTheStoredFormReadsWhereRequiresIsAListOfNames(t *testing.T) { - m := mustParse(t, `{"module":"grafana","version":"1", + m := mustParse(t, `{"module":"grafana","version":"1.0", "requires":["route",{"name":"influxdb-api","optional":true}]}`) raw, err := json.Marshal(m) if err != nil { @@ -5017,7 +5017,7 @@ func caseTheStoredFormReadsWhereRequiresIsAListOfNames(t *testing.T) { } } // 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 { + if _, err := ParseManifest([]byte(`{"module":"x","version":"1.0","requires":["route"],"optional-requires":["mqtt-topic"]}`)); err == nil { t.Error("an optional requirement the module does not require passed the check") } } @@ -5036,7 +5036,7 @@ func caseTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) { 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"}]}`) + raw := []byte(`{"module":"networkmanager","version":"1.0","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) } @@ -5055,7 +5055,7 @@ func caseTheWorldsNamesAreNotAnInstallations(t *testing.T) { } func caseTwoArtifactsWithOneNameAreRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + _, err := ParseManifest([]byte(`{"module":"a","version":"1.0","build":{"artifacts":[ {"name":"x","kind":"image","from":"Dockerfile"}, {"name":"x","kind":"archive","from":"files"}]}}`)) if err == nil { @@ -5191,12 +5191,12 @@ func caseWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) { "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 + `]}` + raw := `{"module":"m","version":"1.0","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"]}]}` + ok := `{"module":"m","version":"1.0","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) } @@ -5328,7 +5328,7 @@ func problemsFor(t *testing.T, shelf catalogue.Shelf) string { } func claimed(claims string) []byte { - return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh","credential":{"form":"none"}}],"claims":` + claims + `}`) + return []byte(`{"module":"thing","version":"1.0","provides":[{"name":"npm-package-registry","scope":"mesh","credential":{"form":"none"}}],"claims":` + claims + `}`) } func hostnameSeat(t *testing.T) catalogue.Seat { @@ -5354,7 +5354,7 @@ func mustParse(t *testing.T, raw string) catalogue.Manifest { return m } -const declaredStore = `{"module":"pg","version":"1", +const declaredStore = `{"module":"pg","version":"1.0", "provides":[{"name":"postgres-database","scope":"mesh"}], "grants":{"postgres-database":"${dir:grants}"}, "data":{ @@ -5376,7 +5376,7 @@ func shallowCopy(resource map[string]any) map[string]any { return copied } -const templatedTwin = `{"module":"tidy","version":"1", +const templatedTwin = `{"module":"tidy","version":"1.0", "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, "resources":[ {"id":"conf","type":"directory","path":"${dir:conf}"}, @@ -5417,7 +5417,7 @@ func aChannel(module, kind string) catalogue.Manifest { func aStoreManifest(step map[string]any) []byte { m := map[string]any{ - "module": "distribution", "version": "1", + "module": "distribution", "version": "1.0", "resources": []any{ map[string]any{"id": "store", "type": "container", "name": "mesh-registry", "image": "registry@sha256:" + strings.Repeat("a", 64)}, @@ -5430,7 +5430,7 @@ func aStoreManifest(step map[string]any) []byte { func familyManifest(t *testing.T, ownSecrets string) (catalogue.Manifest, error) { t.Helper() - return ParseManifest([]byte(`{"module":"mounts","version":"1","own-secrets":{` + ownSecrets + `}}`)) + return ParseManifest([]byte(`{"module":"mounts","version":"1.0","own-secrets":{` + ownSecrets + `}}`)) } // jsonString quotes a Go string the way encoding/json would, for building a literal into a test's manifest JSON. @@ -5471,7 +5471,7 @@ func fallingBackTo(t *testing.T, key string) []byte { func inConfigForm() map[string]any { return map[string]any{ "module": "notifier", - "version": "1", + "version": "1.0", "config": map[string]any{ "font-size": map[string]any{"type": "number", "description": "the notification's font size, in points", "minimum": 6, "maximum": 40, "default": 10}, @@ -5617,7 +5617,7 @@ func parsed(t *testing.T, m catalogue.Manifest) error { } func upstreamManifest(artifact string) string { - return `{"module":"a","version":"1","build":{"artifacts":[` + artifact + `]}}` + return `{"module":"a","version":"1.0","build":{"artifacts":[` + artifact + `]}}` } const pinnedSum = "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2" @@ -5639,7 +5639,7 @@ func mailSenderProcessManifest(t *testing.T, bus string) string { // stream the module owns, never its retention — that rule is 1178's (WorkQueueStreamProblems), and is // tested against its own fixture there (work_queue_consumer_test.go) so this one stays accepted whether // or not a process currently names "mail-sends" in bus.queues. - return `{"module":"mesh-mail","version":"1","emits":["mail.send.sent","mail.send.failed"],` + + return `{"module":"mesh-mail","version":"1.0","emits":["mail.send.sent","mail.send.failed"],` + `"state":["sends"],"streams":[{"name":"mail-sends","events":["mail.send.approved"],"max-age-days":1,` + `"max-mib":64}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + process + `]}` } @@ -5659,7 +5659,7 @@ func strSliceEqual(a, b []string) bool { return true } -const handedProvider = `{"module":"db","version":"1","moved":true, +const handedProvider = `{"module":"db","version":"1.0","moved":true, "provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}], "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, "resources":[ @@ -5738,7 +5738,7 @@ func ownAccountManifest(t *testing.T, runsAs bool, health any) []byte { return raw } -const aRegistryForge = `{"module":"forge","version":"1","requires":["route"], +const aRegistryForge = `{"module":"forge","version":"1.0","requires":["route"], "provides":[{"name":"npm-package-registry","scope":"mesh"}], "contributes":{"route":{"web":{"label":"git","port":3000}}}, "serves":{"npm-package-registry":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/", @@ -5768,7 +5768,7 @@ func store() catalogue.Manifest { // that another entry knows is kept (novox/hq ADR 0262). func aModuleWithAFieldSetAsideStillProvidesWhatItProvides(t *testing.T) { var m catalogue.Manifest - raw := `{"module": "later", "version": "2", + raw := `{"module": "later", "version":"2.0", "provides": [{"name": "db", "scope": "mesh"}, {"name": "cache", "a-field-from-later": 1}], "state": [{"name": "s", "history": 3}], "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "d", "class": "later"}}]}, @@ -5807,13 +5807,13 @@ func notifier() catalogue.Manifest { // the trusted mergeable file that takes its grants setting; extra is spliced in at the top. func mailManifest(t *testing.T, streams, extra string) string { t.Helper() - return `{"module":"mesh-mail","version":"1",` + extra + `"streams":` + streams + `,` + + return `{"module":"mesh-mail","version":"1.0",` + extra + `"streams":` + streams + `,` + `"resources":[{"id":"state","type":"directory","mode":"0700","place":"."},` + `{"id":"grants","trusted":true,"type":"file","path":"${dir:state}/grants.json","mode":"0600",` + `"merge":"json","takes-settings":["grants"],"content":"{\"grants\": {}}"}]}` } -const unpackingTemplated = `{"module":"tidy","version":"1", +const unpackingTemplated = `{"module":"tidy","version":"1.0", "templates":"templates", "build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"}, {"name":"templates","kind":"archive","from":"templates"}]}, @@ -5823,7 +5823,7 @@ const unpackingTemplated = `{"module":"tidy","version":"1", {"id":"main","type":"file","path":"/etc/tidy.conf","mode":"0644","template":"templates/tidy.conf"}, {"id":"extra","type":"file","path":"/etc/extra.conf","template":"templates/deeper/extra.conf"}]}` -const unpackingInline = `{"module":"tidy","version":"1", +const unpackingInline = `{"module":"tidy","version":"1.0", "build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"}]}, "resources":[ {"id":"scripts","type":"archive","path":"/usr/local/lib/tidy","artifact":"scripts"}, @@ -5852,7 +5852,7 @@ func mod(name string, provides, requires, capabilities []string, claims ...catal Capabilities: capabilities, Claims: claims} } -const collectorZsh = `{"module":"zsh","version":"1","moved":true, +const collectorZsh = `{"module":"zsh","version":"1.0","moved":true, "claims":[{"name":"node-login-shell","scope":"node"}], "collections":[{"name":"widgets","comment":"#"}], "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, @@ -5968,7 +5968,7 @@ func catalogueManifest(t *testing.T, module string) catalogue.Manifest { } func optionalModule(resources string) string { - return `{"module":"grafana","version":"1", + return `{"module":"grafana","version":"1.0", "requires":["route",{"name":"influxdb-api","optional":true}], "secrets":{"influxdb-api":"/var/lib/grafana/influxdb-api"}, "resources":[` + resources + `]}` @@ -5978,7 +5978,7 @@ const aContainerMounting = `{"id":"server","type":"container","name":"store","im `0000000000000000000000000000000000000000000000000000000000000000","volumes":["%s:/inside"]}` func botMoved() string { - return strings.Replace(handedBot, `"version":"1",`, `"version":"1","moved":true,`, 1) + return strings.Replace(handedBot, `"version":"1.0",`, `"version":"1.0","moved":true,`, 1) } // held is a manifest as registration holds it: resolved with what was built, then read again strictly. @@ -6015,7 +6015,7 @@ const aServiceDigest = "sha256:ddddddddddddddddddddddddddddddddddddddddddddddddd const healthDigest = "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -const handedNotifier = `{"module":"notify","version":"1", +const handedNotifier = `{"module":"notify","version":"1.0", "config":{"width":{"type":"number","description":"How wide a notification is, in pixels.","default":250}}, "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, "resources":[ @@ -6025,7 +6025,7 @@ const handedNotifier = `{"module":"notify","version":"1", {"id":"notifier","type":"service","unit":"notify.service","scope":"user","user":"${machine:account}","reload-on":["configuration"]}]}` func notifierMoved() string { - return strings.Replace(handedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1) + return strings.Replace(handedNotifier, `"version":"1.0",`, `"version":"1.0","moved":true,`, 1) } const mainText = `# tidy\nroot = ${dir:conf}\nquoted = \"yes\" é\n` @@ -6082,7 +6082,7 @@ func moduleTree(t *testing.T, module string) (root, dir string) { return beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), module) } -const handedBot = `{"module":"bot","version":"1", +const handedBot = `{"module":"bot","version":"1.0", "own-secrets":{"token":"${dir:state}/token"}, "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, "resources":[ From cafff10266988eb0d1e3bb7e0f7f0978be9957e9 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 11:32:31 +0100 Subject: [PATCH 6/9] RN1: the pipeline runner's module, a foundation part with its kept data in old sections (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module mesh-delivery-pipeline claims the seat mesh-pipeline-runner, serving its twelve verbs, asks no template, keeps pipelines, kept, aged, findings and its first views in old state sections, its step log on an old stream (128 MiB) and its step outputs in an old objects section (1 GiB), hears the events its pipelines start and end on, and needs the assigner's program delivered first. Its program stops when its state pipelines cannot be read; its own module is its account's. Builds: design 47 G §1, §21, §27, §59, §85, §95, §101, §130, §333 Not built, waiting on design answers: §76's health check (its tool's name), §85's caps of the old state sections (no size field on an old state), step-log's 100 days (the validator's 30-day bound), and §59's sent-objects (the objects template's cap the design names has no default). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../cmd/mesh-delivery-pipeline/main.go | 51 +++++++++ modules/mesh-delivery-pipeline/go.mod | 17 +++ modules/mesh-delivery-pipeline/go.sum | 12 +++ .../mesh-delivery-pipeline/manifest_test.go | 77 ++++++++++++++ modules/mesh-delivery-pipeline/module.json | 100 ++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go create mode 100644 modules/mesh-delivery-pipeline/go.mod create mode 100644 modules/mesh-delivery-pipeline/go.sum create mode 100644 modules/mesh-delivery-pipeline/manifest_test.go create mode 100644 modules/mesh-delivery-pipeline/module.json diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go new file mode 100644 index 00000000..a931e9eb --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go @@ -0,0 +1,51 @@ +// Command mesh-delivery-pipeline is the pipeline runner, holder of the seat mesh-pipeline-runner, a foundation part +// whose kept data are in its manifest's old sections (design 47 G §21, §27, §101). It names no module, branch or +// repository of its own (§7): its own module is the one its bus account names. +package main + +import ( + "context" + "fmt" + "io" + "log" + "os" + "os/signal" + "syscall" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner" + "git.novox.be/novox/mesh-sdk/go/process" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, os.Getenv, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, "pipeline runner:", err) + os.Exit(1) + } +} + +// run is the program: it returns when ctx ends, or at once when the bus or the state pipelines cannot be used. +func run(ctx context.Context, getenv func(string) string, logOut io.Writer) error { + cred, err := process.ReadBrokerAccount(getenv) + if err != nil { + return err + } + logger := log.New(logOut, cred.Module+": ", log.LstdFlags) + conn, js, err := cred.Dial() + if err != nil { + return err + } + defer conn.Close() + // The state pipelines, `_pipelines`, holds every pipeline by compare-and-set (§27): without it the + // runner keeps nothing, so it stops. + if _, err := process.EssentialBucket(ctx, js, "pipeline runner", ownBucket(cred.Module, seat.StatePipelines), "keep a pipeline"); err != nil { + return err + } + logger.Printf("holding %s", seat.Seat) + <-ctx.Done() + return nil +} + +// ownBucket is a state of the runner's old state section on the bus, `_`. +func ownBucket(module, state string) string { return module + "_" + state } diff --git a/modules/mesh-delivery-pipeline/go.mod b/modules/mesh-delivery-pipeline/go.mod new file mode 100644 index 00000000..511f96a5 --- /dev/null +++ b/modules/mesh-delivery-pipeline/go.mod @@ -0,0 +1,17 @@ +module mesh-delivery-pipeline + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 + github.com/nats-io/nats.go v1.54.0 +) + +require ( + github.com/klauspost/compress v1.20.1 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/modules/mesh-delivery-pipeline/go.sum b/modules/mesh-delivery-pipeline/go.sum new file mode 100644 index 00000000..0daef11f --- /dev/null +++ b/modules/mesh-delivery-pipeline/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/modules/mesh-delivery-pipeline/manifest_test.go b/modules/mesh-delivery-pipeline/manifest_test.go new file mode 100644 index 00000000..59aa7378 --- /dev/null +++ b/modules/mesh-delivery-pipeline/manifest_test.go @@ -0,0 +1,77 @@ +package pipeline_test + +import ( + "os" + "slices" + "testing" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// TestManifest holds the runner's manifest to its design lines, row by row. +func TestManifest(t *testing.T) { + raw, err := os.ReadFile("module.json") + if err != nil { + t.Fatal(err) + } + m, err := catalogue.Decode(raw) + if err != nil { + t.Fatalf("the manifest does not read: %v", err) + } + var states, streams, objects []string + for _, s := range m.State { + states = append(states, s.Name) + } + for _, s := range m.Streams { + streams = append(streams, s.Name) + } + for _, o := range m.Objects { + objects = append(objects, o.Name) + } + var program catalogue.Artifact + if m.Build == nil { + t.Fatal("the manifest builds nothing") + } + for _, a := range m.Build.Artifacts { + if a.Name == "program" { + program = a + } + } + rows := []struct { + name string + ok bool + }{ + // RN1/AC1 (§21): a foundation part asks no template; its kept data are in old sections. + {"no requires", len(m.Requires) == 0}, + {"claims the seat serving every verb of its definition", len(m.Claims) == 1 && m.Claims[0].Name == seat.Seat && + equalSets(m.Claims[0].Serves, seat.Verbs())}, + // §27, §85, §330, §355, §77: the old state sections. + {"old state sections", equalSets(states, []string{seat.StatePipelines, seat.StateKept, "aged", "findings", "first-views"})}, + // §27, §85: the step log on an old stream, its outputs in an old objects section. + {"old streams section", equalSets(streams, []string{seat.StateStepLog})}, + {"step-log 128 MiB", len(m.Streams) == 1 && m.Streams[0].MaxMiB == 128}, + {"old objects section", equalSets(objects, []string{seat.StateStepOutputs})}, + {"step-outputs 1 GiB", len(m.Objects) == 1 && m.Objects[0].MaxMiB == 1024}, + // §333, §54, §44, §53, §376, §24, §37, §4, §77: what the runner hears. + {"consumes", equalSets(m.Consumes, []string{ + "mesh-delivery-indexer.take-in.taken", "mesh-builds.package-published", "mesh-builds.toolchain-agreed", + "operator-channel.decided", "node-build-go.step-ended", "node-build-node.step-ended", + "node-build-image.step-ended", "mesh-wants.step-ended", "mesh-catalog.registration-changed", + "mesh-catalog.catalog-revision"})}, + // RN1/AC5 (§95, §130): the new assigner is delivered before the runner. + {"program needs the assigner's program", slices.Equal(program.Needs, []string{"mesh-delivery-assigner:program"})}, + } + for _, r := range rows { + if !r.ok { + t.Errorf("%s: not as the design says", r.name) + } + } +} + +func equalSets(a, b []string) bool { + x, y := slices.Clone(a), slices.Clone(b) + slices.Sort(x) + slices.Sort(y) + return slices.Equal(x, y) +} diff --git a/modules/mesh-delivery-pipeline/module.json b/modules/mesh-delivery-pipeline/module.json new file mode 100644 index 00000000..02802158 --- /dev/null +++ b/modules/mesh-delivery-pipeline/module.json @@ -0,0 +1,100 @@ +{ + "module": "mesh-delivery-pipeline", + "version": "1", + "slug": "pipeline", + "claims": [ + { + "name": "mesh-pipeline-runner", + "scope": "mesh", + "serves": [ + "run", + "start", + "show", + "list", + "stands", + "why-carried", + "kept", + "review", + "rerun", + "accept", + "cancel", + "ask-again" + ] + } + ], + "consumes": [ + "mesh-delivery-indexer.take-in.taken", + "mesh-builds.package-published", + "mesh-builds.toolchain-agreed", + "operator-channel.decided", + "node-build-go.step-ended", + "node-build-node.step-ended", + "node-build-image.step-ended", + "mesh-wants.step-ended", + "mesh-catalog.registration-changed", + "mesh-catalog.catalog-revision" + ], + "state": [ + { "name": "pipelines", "history": 1 }, + { "name": "kept", "history": 1 }, + { "name": "aged", "history": 1 }, + { "name": "findings", "history": 1 }, + { "name": "first-views", "history": 1 } + ], + "streams": [ + { "name": "step-log", "events": ["step-log.**"], "max-mib": 128 } + ], + "objects": [ + { "name": "step-outputs", "max-mib": 1024 } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-delivery-pipeline", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-delivery-pipeline", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-delivery-pipeline" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-delivery-pipeline", + "place": ".", + "unknown": "program" + }, + { + "id": "pipeline", + "type": "process", + "name": "mesh-delivery-pipeline", + "artifact": "program", + "run": [ + "./mesh-delivery-pipeline" + ], + "env": { + "MESH_BROKER_FILE": "${dir:state}/broker" + }, + "user": "mesh-delivery-pipeline", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-pipeline", + "binary": "mesh-delivery-pipeline", + "needs": ["mesh-delivery-assigner:program"] + } + ] + } +} From fc0cb1873ace3a657ae6ed9853d8ed6638730db7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 11:37:51 +0100 Subject: [PATCH 7/9] RN15: the runner's read verbs show, list, stands, why-carried and kept, from its own states (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner's tools bundle serves the seat mesh-pipeline-runner's read verbs as tools named ., answered from its own states pipelines and kept, never another part's: show one pipeline whole with its steps and their outputs' names, list by module, branch, commit or run newest first, stands every step started and not ended with what it waits on and since when, why-carried the pipeline a carried step came from, kept the kept results by level, digest or package. Builds: design 47 G §83, §306, §75 (stands), §25 (why-carried) Not built, waiting on design answers: show's step log (Q-RN6, the step log's section form) and each store's fill against its cap in show and kept (Q-RN2, where the caps are declared). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../cmd/mesh-delivery-pipeline-tools/main.go | 91 +++++++++ .../mesh-delivery-pipeline-tools/main_test.go | 26 +++ .../internal/pipeline/read.go | 191 ++++++++++++++++++ .../internal/pipeline/read_test.go | 129 ++++++++++++ modules/mesh-delivery-pipeline/module.json | 53 ++++- 5 files changed, 482 insertions(+), 8 deletions(-) create mode 100644 modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline-tools/main.go create mode 100644 modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline-tools/main_test.go create mode 100644 modules/mesh-delivery-pipeline/internal/pipeline/read.go create mode 100644 modules/mesh-delivery-pipeline/internal/pipeline/read_test.go diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline-tools/main.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline-tools/main.go new file mode 100644 index 00000000..bebc4d19 --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline-tools/main.go @@ -0,0 +1,91 @@ +// Command mesh-delivery-pipeline-tools serves the seat mesh-pipeline-runner's verbs as tools named . +// (design 47 G §83, §306), answered from the runner's own states; the tool runner describes each from the seat's +// definition, so the tools carry no description or input. +package main + +import ( + "encoding/json" + "fmt" + "os" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner" + stdio "git.novox.be/novox/mesh-sdk/go" + + "mesh-delivery-pipeline/internal/pipeline" +) + +// kept reads one of the runner's states through the runtime. +type kept struct{ s stdio.KeptState } + +func (k kept) Keys() ([]string, error) { return k.s.Keys() } + +func (k kept) Value(key string) (json.RawMessage, error) { + e, err := k.s.Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil +} + +// as decodes a call's arguments into a verb's input, refusing one that does not fit it. +func as[T any](args map[string]any) (T, error) { + var in T + raw, err := json.Marshal(args) + if err == nil { + err = json.Unmarshal(raw, &in) + } + if err != nil { + return in, fmt.Errorf("the arguments do not fit the verb's input: %w", err) + } + return in, nil +} + +// Tools are the runner's read verbs over its states pipelines and kept. +func Tools(r pipeline.Reads) []stdio.Tool { + runs := map[string]func(map[string]any) (any, error){ + seat.VerbShow: func(a map[string]any) (any, error) { + in, err := as[seat.ShowInput](a) + if err != nil { + return nil, err + } + return r.Show(in) + }, + seat.VerbList: func(a map[string]any) (any, error) { + in, err := as[seat.ListInput](a) + if err != nil { + return nil, err + } + return r.List(in) + }, + seat.VerbStands: func(map[string]any) (any, error) { return r.Stands() }, + seat.VerbWhyCarried: func(a map[string]any) (any, error) { + in, err := as[seat.WhyCarriedInput](a) + if err != nil { + return nil, err + } + return r.WhyCarried(in) + }, + seat.VerbKept: func(a map[string]any) (any, error) { + in, err := as[seat.KeptInput](a) + if err != nil { + return nil, err + } + return r.KeptResults(in) + }, + } + var out []stdio.Tool + for _, v := range seat.Verbs() { + if run, ok := runs[v]; ok { + out = append(out, stdio.Tool{Name: seat.Seat + "." + v, Run: run}) + } + } + return out +} + +func main() { + r := pipeline.Reads{Pipelines: kept{stdio.State(seat.StatePipelines)}, Kept: kept{stdio.State(seat.StateKept)}} + if err := stdio.Serve("", Tools(r)); err != nil { + fmt.Fprintf(os.Stderr, "[mesh-delivery-pipeline-tools] %v\n", err) + os.Exit(1) + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline-tools/main_test.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline-tools/main_test.go new file mode 100644 index 00000000..0565790a --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline-tools/main_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "mesh-delivery-pipeline/internal/pipeline" +) + +type none struct{} + +func (none) Keys() ([]string, error) { return nil, nil } +func (none) Value(string) (json.RawMessage, error) { return nil, nil } + +// TestTools: the bundle serves the five read verbs as . (§83, §306; RN15/AC1). +func TestTools(t *testing.T) { + var names []string + for _, tl := range Tools(pipeline.Reads{Pipelines: none{}, Kept: none{}}) { + names = append(names, tl.Name) + } + want := "mesh-pipeline-runner.show mesh-pipeline-runner.list mesh-pipeline-runner.stands mesh-pipeline-runner.why-carried mesh-pipeline-runner.kept" + if got := strings.Join(names, " "); got != want { + t.Errorf("tools %q, want %q", got, want) + } +} diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/read.go b/modules/mesh-delivery-pipeline/internal/pipeline/read.go new file mode 100644 index 00000000..00163110 --- /dev/null +++ b/modules/mesh-delivery-pipeline/internal/pipeline/read.go @@ -0,0 +1,191 @@ +// Package pipeline is the pipeline runner's own logic, holder of the seat mesh-pipeline-runner (design 47 G §21). It names +// no module, branch or repository (§7): every name comes from what it reads. +package pipeline + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner" +) + +// Kept is one of the runner's old state sections as it reads them: every key, and one key's value (nil when absent). +type Kept interface { + Keys() ([]string, error) + Value(key string) (json.RawMessage, error) +} + +// Reads answers the runner's read verbs (§83, §306) from its own states pipelines and kept, never another part's. +type Reads struct { + Pipelines Kept + Kept Kept +} + +// Show is one pipeline whole, with its steps and the names of their outputs in step-outputs (§83). +func (r Reads) Show(in seat.ShowInput) (seat.ShowAnswer, error) { + p, ok, err := r.pipeline(in.Pipeline) + if err != nil { + return seat.ShowAnswer{}, err + } + if !ok { + return seat.ShowAnswer{}, fmt.Errorf("no pipeline %q in %s", in.Pipeline, seat.StatePipelines) + } + a := seat.ShowAnswer{Pipeline: p} + for _, s := range p.Steps { + if s.Output != "" { + a.Outputs = append(a.Outputs, s.Output) + } + } + return a, nil +} + +// List is the pipelines and runs of a module, a branch, a commit or a run, newest started first, at most Limit when it +// names one (§83). +func (r Reads) List(in seat.ListInput) (seat.ListAnswer, error) { + all, err := r.pipelines() + if err != nil { + return seat.ListAnswer{}, err + } + out := []seat.Pipeline{} + for _, p := range all { + if (in.Module == "" || p.Module == in.Module) && (in.Branch == "" || p.Branch == in.Branch) && + (in.Commit == "" || p.Commit == in.Commit) && (in.Run == "" || p.Run == in.Run) { + out = append(out, p) + } + } + sort.SliceStable(out, func(i, j int) bool { return startedOf(out[i]).After(startedOf(out[j])) }) + if in.Limit > 0 && int64(len(out)) > in.Limit { + out = out[:in.Limit] + } + return seat.ListAnswer{Pipelines: out}, nil +} + +// Stands is every step that has started and not ended, with what it waits on and since when (§75, §83): the watchman +// seeds its open waits from it. +func (r Reads) Stands() (seat.StandsAnswer, error) { + all, err := r.pipelines() + if err != nil { + return seat.StandsAnswer{}, err + } + out := []seat.Wait{} + for _, p := range all { + for _, s := range p.Steps { + if s.Ended != nil || s.Started == nil { + continue + } + if s.Outcome != seat.StepResultOutcomeRunning && s.Outcome != seat.StepResultOutcomeWaiting { + continue + } + out = append(out, seat.Wait{Pipeline: p.Pipeline, Module: p.Module, Branch: p.Branch, Commit: p.Commit, + Group: p.Group, Run: p.Run, Source: p.Source, Revision: p.Revision, Fp: p.Fp, Step: s.Step, + Artifact: s.Artifact, On: s.On, Since: *s.Started, V: 1}) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Since.Before(out[j].Since) }) + return seat.StandsAnswer{Stands: out}, nil +} + +// WhyCarried is where a step's result was carried from (§25, §83): the pipeline it names. +func (r Reads) WhyCarried(in seat.WhyCarriedInput) (seat.WhyCarriedAnswer, error) { + p, ok, err := r.pipeline(in.Pipeline) + if err != nil { + return seat.WhyCarriedAnswer{}, err + } + if !ok { + return seat.WhyCarriedAnswer{}, fmt.Errorf("no pipeline %q in %s", in.Pipeline, seat.StatePipelines) + } + for _, s := range p.Steps { + if s.Step != in.Step || (in.Artifact != "" && s.Artifact != in.Artifact) { + continue + } + if s.Outcome != seat.StepResultOutcomeCarried { + return seat.WhyCarriedAnswer{}, fmt.Errorf("step %s of %s %s was not carried: it ended %q", s.Step, + in.Pipeline, s.Artifact, s.Outcome) + } + return seat.WhyCarriedAnswer{CarriedFrom: s.CarriedFrom}, nil + } + return seat.WhyCarriedAnswer{}, fmt.Errorf("pipeline %q has no step %s %s", in.Pipeline, in.Step, in.Artifact) +} + +// KeptResults is the kept passing results, by level, digest or package (§83). +func (r Reads) KeptResults(in seat.KeptInput) (seat.KeptAnswer, error) { + keys, err := r.Kept.Keys() + if err != nil { + return seat.KeptAnswer{}, fmt.Errorf("%s cannot be read: %w", seat.StateKept, err) + } + sort.Strings(keys) + out := []seat.KeptResult{} + for _, k := range keys { + level, digest, ok := seat.ParseKeptByLevelDigestKey(k) + if !ok || (in.Level != "" && level != in.Level) || (in.Digest != "" && digest != in.Digest) { + continue + } + raw, err := r.Kept.Value(k) + if err != nil { + return seat.KeptAnswer{}, fmt.Errorf("%s %s cannot be read: %w", seat.StateKept, k, err) + } + if raw == nil { + continue + } + v, err := seat.DecodeKeptByLevelDigestValue(raw) + if err != nil { + return seat.KeptAnswer{}, fmt.Errorf("%s %s: %w", seat.StateKept, k, err) + } + if in.Package != "" && v.Package != in.Package { + continue + } + out = append(out, v) + } + return seat.KeptAnswer{Kept: out}, nil +} + +func (r Reads) pipeline(key string) (seat.Pipeline, bool, error) { + key = strings.TrimSpace(key) + if key == "" { + return seat.Pipeline{}, false, fmt.Errorf("name the pipeline's key, ..") + } + raw, err := r.Pipelines.Value(key) + if err != nil { + return seat.Pipeline{}, false, fmt.Errorf("%s %s cannot be read: %w", seat.StatePipelines, key, err) + } + if raw == nil { + return seat.Pipeline{}, false, nil + } + p, err := seat.DecodePipelinesByBranchRevisionModuleValue(raw) + if err != nil { + return seat.Pipeline{}, false, fmt.Errorf("%s %s: %w", seat.StatePipelines, key, err) + } + if p.Pipeline == "" { + p.Pipeline = key + } + return p, true, nil +} + +func (r Reads) pipelines() ([]seat.Pipeline, error) { + keys, err := r.Pipelines.Keys() + if err != nil { + return nil, fmt.Errorf("%s cannot be read: %w", seat.StatePipelines, err) + } + sort.Strings(keys) + var out []seat.Pipeline + for _, k := range keys { + p, ok, err := r.pipeline(k) + if err != nil { + return nil, err + } + if ok { + out = append(out, p) + } + } + return out, nil +} + +func startedOf(p seat.Pipeline) time.Time { + if p.Started == nil { + return time.Time{} + } + return *p.Started +} diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/read_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/read_test.go new file mode 100644 index 00000000..af516211 --- /dev/null +++ b/modules/mesh-delivery-pipeline/internal/pipeline/read_test.go @@ -0,0 +1,129 @@ +package pipeline + +import ( + "encoding/json" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner" +) + +type memKept map[string]json.RawMessage + +func (m memKept) Keys() ([]string, error) { + var out []string + for k := range m { + out = append(out, k) + } + return out, nil +} + +func (m memKept) Value(key string) (json.RawMessage, error) { return m[key], nil } + +func put(t *testing.T, m memKept, key string, v any) { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + m[key] = raw +} + +// TestReads holds the runner's read verbs to §83 and §306, one row per case (RN15/AC1, test 93's runner case). +func TestReads(t *testing.T) { + t0 := time.Date(2026, 11, 11, 10, 0, 0, 0, time.UTC) + t1, t2 := t0.Add(time.Minute), t0.Add(2*time.Minute) + pipes, kept := memKept{}, memKept{} + put(t, pipes, "main.7.alpha", seat.Pipeline{Pipeline: "main.7.alpha", Module: "alpha", Branch: "main", Commit: "c7", + Source: 7, Outcome: seat.PipelineOutcomeRunning, Started: &t0, Steps: []seat.StepResult{ + {Step: seat.StepResultStepLint, Artifact: "alpha:program", Status: "required", Outcome: seat.StepResultOutcomePassed, + Started: &t0, Ended: &t1, Output: "main.7.alpha.lint.alpha:program"}, + {Step: seat.StepResultStepUnit, Artifact: "alpha:program", Status: "required", Outcome: seat.StepResultOutcomeCarried, + CarriedFrom: "main.6.alpha"}, + {Step: seat.StepResultStepApproval, Status: "required", Outcome: seat.StepResultOutcomeWaiting, On: seat.WaitOnPerson, + Started: &t1}, + }}) + put(t, pipes, "beta.3.alpha", seat.Pipeline{Pipeline: "beta.3.alpha", Module: "alpha", Branch: "beta", Commit: "b3", + Outcome: seat.PipelineOutcomePassed, Started: &t2}) + put(t, pipes, "main.7.gamma", seat.Pipeline{Pipeline: "main.7.gamma", Module: "gamma", Branch: "main", Commit: "c7", + Outcome: seat.PipelineOutcomeRunning, Started: &t1}) + put(t, kept, "unit.sha256-aa", seat.KeptResult{Level: "unit", Digest: "sha256-aa", Package: "alpha/x"}) + put(t, kept, "integration.sha256-bb", seat.KeptResult{Level: "integration", Digest: "sha256-bb", Package: "alpha/y"}) + r := Reads{Pipelines: pipes, Kept: kept} + + names := func(ps []seat.Pipeline) string { + var s []string + for _, p := range ps { + s = append(s, p.Pipeline) + } + return strings.Join(s, " ") + } + rows := []struct { + name string + got func() (string, error) + want string + }{ + {"show answers the pipeline whole and its outputs", func() (string, error) { + a, err := r.Show(seat.ShowInput{Pipeline: "main.7.alpha"}) + return a.Pipeline.Module + " " + strings.Join(a.Outputs, ","), err + }, "alpha main.7.alpha.lint.alpha:program"}, + {"show refuses a pipeline it does not hold", func() (string, error) { + _, err := r.Show(seat.ShowInput{Pipeline: "main.9.alpha"}) + return "", err + }, `error: no pipeline "main.9.alpha" in pipelines`}, + {"list by module, newest first", func() (string, error) { + a, err := r.List(seat.ListInput{Module: "alpha"}) + return names(a.Pipelines), err + }, "beta.3.alpha main.7.alpha"}, + {"list by commit", func() (string, error) { + a, err := r.List(seat.ListInput{Commit: "c7"}) + return names(a.Pipelines), err + }, "main.7.gamma main.7.alpha"}, + {"list by branch with a limit", func() (string, error) { + a, err := r.List(seat.ListInput{Branch: "main", Limit: 1}) + return names(a.Pipelines), err + }, "main.7.gamma"}, + {"stands names a waiting step and since when", func() (string, error) { + a, err := r.Stands() + var s []string + for _, w := range a.Stands { + s = append(s, w.Pipeline+" "+w.Step+" "+w.On+" "+w.Since.Format("15:04")) + } + return strings.Join(s, ";"), err + }, "main.7.alpha approval person 10:01"}, + {"why-carried names where a carried step came from", func() (string, error) { + a, err := r.WhyCarried(seat.WhyCarriedInput{Pipeline: "main.7.alpha", Step: "unit", Artifact: "alpha:program"}) + return a.CarriedFrom, err + }, "main.6.alpha"}, + {"why-carried refuses a step that ran", func() (string, error) { + _, err := r.WhyCarried(seat.WhyCarriedInput{Pipeline: "main.7.alpha", Step: "lint"}) + return "", err + }, `error: step lint of main.7.alpha alpha:program was not carried: it ended "passed"`}, + {"kept by level", func() (string, error) { + a, err := r.KeptResults(seat.KeptInput{Level: "unit"}) + var s []string + for _, k := range a.Kept { + s = append(s, k.Package) + } + return strings.Join(s, " "), err + }, "alpha/x"}, + {"kept by package", func() (string, error) { + a, err := r.KeptResults(seat.KeptInput{Package: "alpha/y"}) + var s []string + for _, k := range a.Kept { + s = append(s, k.Digest) + } + return strings.Join(s, " "), err + }, "sha256-bb"}, + } + for _, row := range rows { + got, err := row.got() + if err != nil { + got = "error: " + err.Error() + } + if got != row.want { + t.Errorf("%s: got %q, want %q", row.name, got, row.want) + } + } +} diff --git a/modules/mesh-delivery-pipeline/module.json b/modules/mesh-delivery-pipeline/module.json index 02802158..3a618fe0 100644 --- a/modules/mesh-delivery-pipeline/module.json +++ b/modules/mesh-delivery-pipeline/module.json @@ -35,17 +35,41 @@ "mesh-catalog.catalog-revision" ], "state": [ - { "name": "pipelines", "history": 1 }, - { "name": "kept", "history": 1 }, - { "name": "aged", "history": 1 }, - { "name": "findings", "history": 1 }, - { "name": "first-views", "history": 1 } + { + "name": "pipelines", + "history": 1 + }, + { + "name": "kept", + "history": 1 + }, + { + "name": "aged", + "history": 1 + }, + { + "name": "findings", + "history": 1 + }, + { + "name": "first-views", + "history": 1 + } ], "streams": [ - { "name": "step-log", "events": ["step-log.**"], "max-mib": 128 } + { + "name": "step-log", + "events": [ + "step-log.**" + ], + "max-mib": 128 + } ], "objects": [ - { "name": "step-outputs", "max-mib": 1024 } + { + "name": "step-outputs", + "max-mib": 1024 + } ], "own-secrets": { "broker": "${dir:state}/broker" @@ -93,7 +117,20 @@ "system": "arch", "from": "cmd/mesh-delivery-pipeline", "binary": "mesh-delivery-pipeline", - "needs": ["mesh-delivery-assigner:program"] + "needs": [ + "mesh-delivery-assigner:program" + ] + }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-pipeline-tools", + "binary": "mesh-delivery-pipeline-tools", + "loads": [ + "mesh-delivery-pipeline-tools" + ] } ] } From 042800cb2a8dcb70fc00a5487d578bf75e95f11f Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 11:41:21 +0100 Subject: [PATCH 8/9] IX15: the indexer reads a branch's source revision through the seat mesh-delivery's verb source and its event source-moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch-up of the heads the source names runs before a source-moved is read, once per branch; the gate's source bucket is no longer read. internal/index compiles against the new contracts again (SourceByMainValue is gone); tests of earlier subtasks that the broken compile hid are fixed (the validator's MAJOR.MINOR version in their manifests). Builds: design 47 G §15, §115, §362 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-delivery-indexer/integration_test.go | 18 ++-- .../cmd/mesh-delivery-indexer/main.go | 27 ++--- .../cmd/mesh-delivery-indexer/main_test.go | 75 ++++++++++--- .../cmd/mesh-delivery-indexer/source.go | 100 +++++++++++++++--- .../internal/index/inputs_test.go | 2 +- .../internal/index/modules_test.go | 15 ++- .../internal/index/names.go | 1 - .../internal/index/previews_test.go | 5 +- .../internal/index/registered_test.go | 5 +- .../internal/index/remove_test.go | 6 +- .../internal/index/source.go | 14 ++- .../internal/index/source_test.go | 32 ++++-- .../internal/index/stale_none2_test.go | 2 +- .../internal/index/toolchains_test.go | 11 +- .../mesh-delivery-indexer/manifest_test.go | 8 +- modules/mesh-delivery-indexer/module.json | 5 +- 16 files changed, 223 insertions(+), 103 deletions(-) diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go index 048f59d7..2d127854 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go @@ -873,16 +873,16 @@ func TestIndexerGivesACandidateItsFingerprintWhenTheCheckerCreatesThePreviewsBuc if _, err := js.CreateOrUpdateConsumer(ctx, PreviewsStream, jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-indexer", AckPolicy: jetstream.AckExplicitPolicy}); err != nil { t.Fatal(err) } - source, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: index.SourceBucket, History: 4}) - if err != nil { - t.Fatal(err) + // The seat mesh-delivery's verb source, as the gate serves it (design 47 G §115): main names the catalogue's trunk head. + answer := func(m *nats.Msg) { + raw, _ := json.Marshal(map[string]any{"result": meshdelivery.SourceAnswer{Branch: "main", Group: "g", Revision: 5, + Repositories: []meshdelivery.SourceHead{{Repository: "novox/mesh-catalog", Head: trunk, Trunk: "main"}}}, "node": "n1"}) + _ = m.Respond(raw) } - data, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{"novox/mesh-catalog": trunk}}) - if err != nil { - t.Fatal(err) - } - if _, err := source.Put(ctx, meshdeliveryclient.SourceMainKey, data); err != nil { - t.Fatal(err) + for _, subject := range []string{"mesh.seat.mesh-delivery.call.source.>", "mesh.seat.mesh-delivery.tool.source"} { + if _, err := js.Conn().Subscribe(subject, answer); err != nil { + t.Fatal(err) + } } }) pull, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgePull, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 301, Head: head, Action: forgemsg.PullUpdated, diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go index abd61bd5..01e3af36 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go @@ -22,6 +22,7 @@ import ( "syscall" "time" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -470,22 +471,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro "indexed from it, until the indexer starts again", builds.BuildLogRead, err) } started.Store(true) - // The gate's source bucket (mesh-delivery-gate_source) comes with the gate (release 3e, novox/hq ADR 0450 §1): its key - // main is the mesh's source revision, which stamps the modules (issue 1344). Where it does not exist yet the indexer does - // not wait for it (that needs a timer, ADR 0437 §8): it says so, and is started again with the gate. - sourceErrs := make(chan error, 1) - if source := process.OptionalBucket(ctx, js, index.SourceBucket, "no source revision is read until the indexer starts again", logger.Printf); source != nil { - each := func(e jetstream.KeyValueEntry) { - if opOf(e) { - applySource(ctx, ix, e.Key(), e.Revision(), e.Value(), logger) - } - } - if sw, err := settle(ctx, source, each); err != nil { - process.NotWatched(logger.Printf, index.SourceBucket, err, "no source revision is read until the indexer starts again") - } else { - go func() { sourceErrs <- follow(ctx, sw, each) }() - } - } + // The source revision of a watched branch (design 47 G §15, §115, §362): asked of the seat mesh-delivery's verb source as a + // kept call, once at the start, before the handler of its event source-moved reads anything; a source-moved heard + // meanwhile is read after. + source := &sourceFollow{read: sourceVia(calls.CallKept), apply: ix.ApplySource, logf: logger.Printf} + go source.catchUp(ctx) // Only the toolchain reports of nodes the catalogue places the build module on count (issue 1940, ADR 0549 §3): the // assignments is settled first, so the toolchains settle through the gate already knowing it. gate := buildrun.NewToolchainGate(sortedModules(), ix.PutToolchain, logger.Printf) @@ -668,6 +658,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro }); err != nil { return err } + if err := hub.Handle(meshdelivery.SubjectSourceMovedAll, func(m jetstream.Msg) { source.heard(ctx, m) }); err != nil { + return err + } hub.OnBound(reads.atBind) // what a node's engine said before the consumer was bound (ADR 0668 §4) versions := func(m jetstream.Msg) { if !heardVersion(ctx, ix, m, logger.Printf) { @@ -753,8 +746,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return nil case err := <-errs: return err - case err := <-sourceErrs: - return err case err := <-assignmentsErrs: return err case err := <-tools.errs: diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go index e64afeeb..1450b161 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go @@ -5,7 +5,9 @@ import ( "context" "encoding/json" "errors" + "fmt" "log" + "reflect" "strings" "testing" "time" @@ -16,7 +18,6 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" "git.novox.be/novox/mesh-sdk/go/process" @@ -237,24 +238,64 @@ type opener struct{ err error } func (o opener) KeyValue(context.Context, string) (jetstream.KeyValue, error) { return nil, o.err } -func TestTheSourceMainKeyIsTakenAtItsRevision(t *testing.T) { // 1344/AC1 (the watch's side: key, revision, value) - var logs bytes.Buffer - logger := log.New(&logs, "", 0) - mods := &memStore{vals: map[string][]byte{}} - ix := &index.Indexer{Store: &memStore{vals: map[string][]byte{}}, Modules: mods, Builds: index.NewBuilds()} - value, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{"novox/mesh-delivery": head}}) - if err != nil { - t.Fatal(err) +func TestTheSourceIsReadFromTheSeatAndASourceMovedDuringTheCatchUpIsReadAfterIt(t *testing.T) { // design 47 G §15, §115, §362 + var order []string + revision := int64(4) + var logs []string + var s *sourceFollow + s = &sourceFollow{ + read: func(branch string) (meshdelivery.SourceAnswer, error) { + order = append(order, "read "+branch) + if revision == 0 { + return meshdelivery.SourceAnswer{}, fmt.Errorf("source.main is not readable") + } + if len(order) == 1 { // a source-moved is heard while the catch-up reads + s.moved(context.Background(), "main") + s.moved(context.Background(), "main") + s.moved(context.Background(), "beta") + } + return meshdelivery.SourceAnswer{Branch: branch, Revision: revision}, nil + }, + apply: func(_ context.Context, a meshdelivery.SourceAnswer) error { + order = append(order, fmt.Sprintf("apply %s %d", a.Branch, a.Revision)) + return nil + }, + logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }, } - applySource(context.Background(), ix, "other", 3, value, logger) // not the key of the mesh's source - applySource(context.Background(), ix, meshdeliveryclient.SourceMainKey, 4, []byte(`{"v":1`), logger) - if !strings.Contains(logs.String(), "source") { - t.Errorf("a source that cannot be read was not said: %q", logs.String()) + s.catchUp(context.Background()) + want := []string{"read main", "apply main 4", "read main", "apply main 4"} // once, after the catch-up; beta is left + if !reflect.DeepEqual(order, want) { + t.Errorf("order = %v, want %v", order, want) } - logs.Reset() - applySource(context.Background(), ix, meshdeliveryclient.SourceMainKey, 5, value, logger) - if logs.Len() != 0 { - t.Errorf("a good source said %q", logs.String()) + s.moved(context.Background(), "main") // after the catch-up: read at once + if len(order) != 6 { + t.Errorf("a source-moved after the catch-up was not read at once: %v", order) + } + order, revision = nil, 0 + s.moved(context.Background(), "main") + if len(order) != 1 || len(logs) != 1 || !strings.Contains(logs[0], "not readable") { + t.Errorf("a source that cannot be read: order %v, said %v, want it said and nothing applied", order, logs) + } +} + +func TestTheSeatsSourceAnswerIsReadAndItsErrorIsAnError(t *testing.T) { // design 47 G §115 + var asked any + read := sourceVia(func(key string, body any) (json.RawMessage, error) { + asked = body + if key != "seat:mesh-delivery.source" { + t.Errorf("asked %q", key) + } + if body.(meshdelivery.SourceInput).Branch == "bad" { + return json.RawMessage(`{"error":"source.bad is not readable"}`), nil + } + return json.RawMessage(`{"branch":"main","revision":7,"repositories":[{"repository":"novox/mesh-delivery","head":"` + head + `","trunk":"main"}]}`), nil + }) + a, err := read("main") + if err != nil || a.Revision != 7 || len(a.Repositories) != 1 || asked.(meshdelivery.SourceInput).Branch != "main" { + t.Errorf("source = %+v, %v", a, err) + } + if _, err := read("bad"); err == nil || !strings.Contains(err.Error(), "not readable") { + t.Errorf("an answer with an error was no error: %v", err) } } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/source.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/source.go index f8aaaad1..10f92615 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/source.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/source.go @@ -2,26 +2,100 @@ package main import ( "context" - "log" + "encoding/json" + "fmt" + "strings" + "sync" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" - - "mesh-delivery-indexer/internal/index" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "github.com/nats-io/nats.go/jetstream" ) -// applySource takes one put of the gate's bucket source into the indexer: the key main, at its revision, which is the mesh's -// source revision (mesh-sdk go/delivery SourceMainKey, Source). Any other key is not the mesh's source. A value that is no -// source is said; so is a write that failed, and it is tried again only at the next revision (novox/hq ADR 0437 §8). -func applySource(ctx context.Context, ix *index.Indexer, key string, rev uint64, value []byte, logger *log.Logger) { - if key != meshdeliveryclient.SourceMainKey { +// sourceInvoke is the seat mesh-delivery's verb source as the manifest's invokes names it: a watched branch's source +// revision, asked of the seat and never read from the gate's state (design 47 G §15, §115; ADR 0596 §2). +const sourceInvoke = "seat:" + meshdelivery.Seat + "." + meshdelivery.VerbSource + +// sourceVia asks the seat for one branch's source revision through call (process.Conn.CallKept, a read) and reads its +// declared answer; an answer that carries an error is that error. +func sourceVia(call toolCaller) func(branch string) (meshdelivery.SourceAnswer, error) { + return func(branch string) (meshdelivery.SourceAnswer, error) { + raw, err := call(sourceInvoke, meshdelivery.SourceInput{Branch: branch}) + if err != nil { + return meshdelivery.SourceAnswer{}, err + } + var a meshdelivery.SourceAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return a, fmt.Errorf("the answer of %s is not a source revision: %w", sourceInvoke, err) + } + if a.Error != "" { + return a, fmt.Errorf("%s: %s", sourceInvoke, a.Error) + } + return a, nil + } +} + +// sourceFollow takes the seat's source revisions into the indexer: first the catch-up of the heads the source names, then +// each source-moved the seat says (design 47 G §362). A source-moved heard before the catch-up ended is applied after it, +// once per branch. +type sourceFollow struct { + read func(branch string) (meshdelivery.SourceAnswer, error) + apply func(context.Context, meshdelivery.SourceAnswer) error + logf func(string, ...any) + + mu sync.Mutex + caught bool + waiting map[string]bool +} + +// catchUp reads the trunk branch's source revision and applies it, then the branches a source-moved named meanwhile. A +// read that fails is said; the catch-up ends all the same, and the next source-moved reads again. +func (s *sourceFollow) catchUp(ctx context.Context) { + s.take(ctx, trunkBranch) + s.mu.Lock() + s.caught = true + branches := s.waiting + s.waiting = nil + s.mu.Unlock() + for branch := range branches { + s.take(ctx, branch) + } +} + +// moved is the handler of one source-moved on branch: held until the catch-up ends, read at once after. +func (s *sourceFollow) moved(ctx context.Context, branch string) { + s.mu.Lock() + if !s.caught { + if s.waiting == nil { + s.waiting = map[string]bool{} + } + s.waiting[branch] = true + s.mu.Unlock() return } - s, err := meshdeliveryclient.DecodeSource(value) + s.mu.Unlock() + s.take(ctx, branch) +} + +// take reads one branch's source revision and applies it. A branch other than the trunk's is left until the indexer keeps +// a catalog graph per watched branch. +func (s *sourceFollow) take(ctx context.Context, branch string) { + if branch != trunkBranch { + return + } + a, err := s.read(branch) if err != nil { - logger.Printf("source.%s at revision %d is not a source, and is left: %v", key, rev, err) + s.logf("source.%s is not read, and is read again at its next source-moved: %v", branch, err) return + } + if err := s.apply(ctx, a); err != nil { + s.logf("source.%s at revision %d could not be taken into the modules: %v", branch, a.Revision, err) } - if err := ix.ApplySource(ctx, rev, s); err != nil { - logger.Printf("source.%s at revision %d could not be taken into the modules: %v", key, rev, err) +} + +// heard is the hub's handler of source-moved: the branch is the last token of the subject. The message is acknowledged at once. +func (s *sourceFollow) heard(ctx context.Context, m jetstream.Msg) { + defer func() { _ = m.Ack() }() + if branch := m.Subject()[strings.LastIndex(m.Subject(), ".")+1:]; branch != "" { + s.moved(ctx, branch) } } diff --git a/modules/mesh-delivery-indexer/internal/index/inputs_test.go b/modules/mesh-delivery-indexer/internal/index/inputs_test.go index 74763916..77adda62 100644 --- a/modules/mesh-delivery-indexer/internal/index/inputs_test.go +++ b/modules/mesh-delivery-indexer/internal/index/inputs_test.go @@ -144,7 +144,7 @@ func TestAnArtifactOfNoToolchainHasNoLanguageAndNoLock(t *testing.T) { // 1450/A func TestAManifestTheParserRefusesLeavesNoInputsAndSaysWhy(t *testing.T) { // 1450/AC5 for name, raw := range map[string]string{ "not json": "not json", - "unknown field": strings.Replace(catalogueManifest(t, "avahi"), `"version":"1.0",`, `"version":"1.0", "scope": "everything",`, 1), + "unknown field": strings.Replace(catalogueManifest(t, "avahi"), `"version": "1",`, `"version": "1", "scope": "everything",`, 1), } { in := inputsOf("avahi", raw, "t", nil, noBases) if in.Kept != nil || !strings.HasPrefix(in.None2, "the manifest cannot be read: ") || len(in.None2) <= len("the manifest cannot be read: ") { diff --git a/modules/mesh-delivery-indexer/internal/index/modules_test.go b/modules/mesh-delivery-indexer/internal/index/modules_test.go index 1d3b78fe..9a4d6fc7 100644 --- a/modules/mesh-delivery-indexer/internal/index/modules_test.go +++ b/modules/mesh-delivery-indexer/internal/index/modules_test.go @@ -14,7 +14,6 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" "git.novox.be/novox/mesh-delivery/sdk/trunk" @@ -160,7 +159,7 @@ func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value if err := ix.Apply(context.Background(), m); err != nil { return err } - if err := ix.ApplySource(context.Background(), 8, sourceOf(repo, next)); err != nil { + if err := applyS(ix, context.Background(), 8, sourceOf(repo, next)); err != nil { return err } ix.Taken = on @@ -169,7 +168,7 @@ func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value {name: "a source head whose merge was not applied is not a taken key", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: "", then: func(ix *Indexer) error { // the forge stream still holds the merge: the consumer is not drained delete(ix.Taken.(*memStore).vals, takenKey(repo)) - if err := ix.ApplySource(context.Background(), 8, sourceOf(repo, next)); err != nil { + if err := applyS(ix, context.Background(), 8, sourceOf(repo, next)); err != nil { return err } return ix.FillTaken(context.Background()) @@ -180,7 +179,7 @@ func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value {name: "a source head of a drained forge stream whose merge was never heard leaves the key", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: merge, then: func(ix *Indexer) error { ix.Contains = func(_ context.Context, _, commit, in string) (bool, error) { return commit == merge && in == next, nil } - if err := ix.ApplySource(context.Background(), 8, sourceOf(repo, next)); err != nil { + if err := applyS(ix, context.Background(), 8, sourceOf(repo, next)); err != nil { return err } return ix.FillTaken(context.Background()) @@ -188,7 +187,7 @@ func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value {name: "a source head of a drained forge stream fills no missing key", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: "", then: func(ix *Indexer) error { delete(ix.Taken.(*memStore).vals, takenKey(repo)) - if err := ix.ApplySource(context.Background(), 8, sourceOf(repo, next)); err != nil { + if err := applyS(ix, context.Background(), 8, sourceOf(repo, next)); err != nil { return err } return ix.FillTaken(context.Background()) @@ -300,7 +299,7 @@ func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value }}, {name: "a later source revision of another repository's merge", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: merge, then: func(ix *Indexer) error { - return ix.ApplySource(context.Background(), 9, meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{repo: merge, "novox/mesh-catalog": otherMerge}}) + return applyS(ix, context.Background(), 9, srcT{Group: "g", Heads: map[string]string{repo: merge, "novox/mesh-catalog": otherMerge}}) }}, } { t.Run(tc.name, func(t *testing.T) { @@ -311,7 +310,7 @@ func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value if err := ix.Apply(ctx, mergedMsg(repo, three)); err != nil { t.Fatal(err) } - if err := ix.ApplySource(ctx, 7, sourceOf(repo, merge)); err != nil { + if err := applyS(ix, ctx, 7, sourceOf(repo, merge)); err != nil { t.Fatal(err) } writes := mods.writes @@ -379,7 +378,7 @@ func TestAMergeWritesTheModulesDirectoryAndKeepsItThroughSourceAndRecompute(t *t t.Errorf("after the merge %s = %+v (%v), want path %q", module, m, ok, want) } } - if err := ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", merge)); err != nil { + if err := applyS(ix, ctx, 7, sourceOf("novox/mesh-delivery", merge)); err != nil { t.Fatal(err) } if err := ix.Recompute(ctx); err != nil { diff --git a/modules/mesh-delivery-indexer/internal/index/names.go b/modules/mesh-delivery-indexer/internal/index/names.go index 6d950393..e3d860d9 100644 --- a/modules/mesh-delivery-indexer/internal/index/names.go +++ b/modules/mesh-delivery-indexer/internal/index/names.go @@ -40,7 +40,6 @@ const ( AssignmentsState = "assignments" AssignmentsBucket = AssignerModule + "_" + AssignmentsState AssignmentsHistory = 5 - SourceBucket = GateModule + "_source" PreviewsBucket = CheckerModule + "_previews" PreviewsHistory = 5 toolchainsState = "toolchains" diff --git a/modules/mesh-delivery-indexer/internal/index/previews_test.go b/modules/mesh-delivery-indexer/internal/index/previews_test.go index 26201cd4..efd893b2 100644 --- a/modules/mesh-delivery-indexer/internal/index/previews_test.go +++ b/modules/mesh-delivery-indexer/internal/index/previews_test.go @@ -17,7 +17,6 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-checker/checkermsg" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" @@ -39,7 +38,7 @@ func previewBed(t *testing.T, report bool) (*Indexer, *memStore, *memStore) { if report { reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") } - if err := ix.ApplySource(context.Background(), 5, meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{catalogueRepo: trunkNow}}); err != nil { + if err := applyS(ix, context.Background(), 5, srcT{Group: "g", Heads: map[string]string{catalogueRepo: trunkNow}}); err != nil { t.Fatal(err) } return ix, mods, ix.Store.(*memStore) @@ -205,7 +204,7 @@ func TestAPreviewMadeOntoAnOldTrunkIsOutOfDate(t *testing.T) { // 1445/AC3 if got := fpOf(t, cands); got.Fp2 == "" { t.Fatalf("after the right preview: %+v", got) } - if err := ix.ApplySource(context.Background(), 6, meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{catalogueRepo: "8888888888888888888888888888888888888888"}}); err != nil { + if err := applyS(ix, context.Background(), 6, srcT{Group: "g", Heads: map[string]string{catalogueRepo: "8888888888888888888888888888888888888888"}}); err != nil { t.Fatal(err) } if got := fpOf(t, cands); got.None2 != NonePreviewStale { diff --git a/modules/mesh-delivery-indexer/internal/index/registered_test.go b/modules/mesh-delivery-indexer/internal/index/registered_test.go index 6492329e..c2437640 100644 --- a/modules/mesh-delivery-indexer/internal/index/registered_test.go +++ b/modules/mesh-delivery-indexer/internal/index/registered_test.go @@ -9,7 +9,6 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-sdk/go/keys" ) @@ -261,7 +260,7 @@ func TestAForgeThatCannotSayLeavesTheModuleUnwrittenAndAsksAgain(t *testing.T) { t.Fatal(err) } case "a new source revision": - if err := ix.ApplySource(ctx, 2, meshdelivery.SourceByMainValue{Heads: map[string]string{"jschoubben/snake": h2, "novox/other": h1}}); err != nil { + if err := applyS(ix, ctx, 2, srcT{Heads: map[string]string{"jschoubben/snake": h2, "novox/other": h1}}); err != nil { t.Fatal(err) } ix.retryWG.Wait() // read again in the background @@ -281,7 +280,7 @@ func TestAForgeThatCannotSayLeavesTheModuleUnwrittenAndAsksAgain(t *testing.T) { } // Taken in, it is read no more: a further event asks the forge nothing. n := len(asked) - if err := ix.ApplySource(ctx, 3, meshdelivery.SourceByMainValue{Heads: map[string]string{"jschoubben/snake": h2}}); err != nil { + if err := applyS(ix, ctx, 3, srcT{Heads: map[string]string{"jschoubben/snake": h2}}); err != nil { t.Fatal(err) } ix.retryWG.Wait() diff --git a/modules/mesh-delivery-indexer/internal/index/remove_test.go b/modules/mesh-delivery-indexer/internal/index/remove_test.go index 8c1b5042..e5864ca2 100644 --- a/modules/mesh-delivery-indexer/internal/index/remove_test.go +++ b/modules/mesh-delivery-indexer/internal/index/remove_test.go @@ -100,7 +100,7 @@ func TestAPathWithoutAModuleJSONIsNeverAModule(t *testing.T) { // 1900/AC2: test func TestStartingDeletesTheKeysTheTrunkDoesNotHold(t *testing.T) { // 1900/AC3 ix, mods := twoModules(t) ctx := context.Background() - if err := ix.ApplySource(ctx, 3, sourceOf(catalogueRepo, merge)); err != nil { + if err := applyS(ix, ctx, 3, sourceOf(catalogueRepo, merge)); err != nil { t.Fatal(err) } var asked string @@ -126,7 +126,7 @@ func TestStartingDeletesTheKeysTheTrunkDoesNotHold(t *testing.T) { // 1900/AC3 func TestStartingDeletesNothingWhenTheTrunkCannotBeRead(t *testing.T) { // 1900/AC3: failure is loud, never a mass delete ix, mods := twoModules(t) ctx := context.Background() - if err := ix.ApplySource(ctx, 3, sourceOf(catalogueRepo, merge)); err != nil { + if err := applyS(ix, ctx, 3, sourceOf(catalogueRepo, merge)); err != nil { t.Fatal(err) } for name, trunk := range map[string]func(context.Context, string, string) ([]string, error){ @@ -146,7 +146,7 @@ func TestAKeyWrittenAheadOfTheSourceHeadStaysAndOneWithoutAPathIsSkipped(t *test ix, mods := twoModules(t) // written at the merge commit C ctx := context.Background() const older = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - if err := ix.ApplySource(ctx, 3, sourceOf(catalogueRepo, older)); err != nil { + if err := applyS(ix, ctx, 3, sourceOf(catalogueRepo, older)); err != nil { t.Fatal(err) } mods.put("nopath", mustEncode(t, meshcatalog.ModulesByModuleValue{Repository: catalogueRepo, Commit: older, None2: "x", Manifest: strings.Repeat("a", 64)})) diff --git a/modules/mesh-delivery-indexer/internal/index/source.go b/modules/mesh-delivery-indexer/internal/index/source.go index 2b67be36..62123afe 100644 --- a/modules/mesh-delivery-indexer/internal/index/source.go +++ b/modules/mesh-delivery-indexer/internal/index/source.go @@ -11,8 +11,9 @@ import ( "git.novox.be/novox/mesh-delivery/sdk/trunk" ) -// The gate's source revision (novox/hq ADR 0437 §5, ADR 0450 §1): source.main names every repository's merged head, and its -// revision in the bucket is the mesh's source revision. The indexer keeps the latest it has seen. A module is stamped +// The gate's source revision of a watched branch (design 47 G §15, §115), read through the seat mesh-delivery's verb source +// and its event source-moved, never the gate's state: it names every repository's merged head, and its revision is the +// mesh's source revision of that branch. The indexer keeps the latest it has seen. A module is stamped // with it (Module.Src) when the head the source names for its repository is the merge commit the module's fingerprint // was computed at: from the modules bucket when the merge was heard first, and when the merge is heard after the source. @@ -33,12 +34,17 @@ func (ix *Indexer) srcOf(repository, merge string) uint64 { return 0 } -// ApplySource takes a revision of the gate's source.main in. An older revision than the latest seen is left. For each +// ApplySource takes the source revision the verb source answered in. An older revision than the latest seen is left. For each // repository the source names, each module built from it whose fingerprint was computed at the head the source names, and // that carries no Src yet, is written again with Src the revision (once: a later revision leaves it); a module computed at another commit is left (a merge heard later is stamped // when it is written, applyMerged), and a repository none of the indexer's modules is built from writes nothing. The // modules of a repository are those the modules bucket holds of it (ADR 0501 clause 6). -func (ix *Indexer) ApplySource(ctx context.Context, rev uint64, s meshdelivery.SourceByMainValue) error { +func (ix *Indexer) ApplySource(ctx context.Context, a meshdelivery.SourceAnswer) error { + rev := uint64(a.Revision) + s := struct{ Heads map[string]string }{Heads: map[string]string{}} + for _, r := range a.Repositories { + s.Heads[r.Repository] = r.Head + } ix.kickJudge(ctx) ix.mu.Lock() if rev < ix.source.rev { diff --git a/modules/mesh-delivery-indexer/internal/index/source_test.go b/modules/mesh-delivery-indexer/internal/index/source_test.go index 0bed051a..9fc5b906 100644 --- a/modules/mesh-delivery-indexer/internal/index/source_test.go +++ b/modules/mesh-delivery-indexer/internal/index/source_test.go @@ -10,8 +10,20 @@ import ( const otherMerge = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -func sourceOf(repo, head string) meshdelivery.SourceByMainValue { - return meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{repo: head}} +// srcT is a source revision's heads as the tests write them; applyS says it as the verb source answers it. +type srcT struct { + Group string + Heads map[string]string +} + +func sourceOf(repo, head string) srcT { return srcT{Group: "g", Heads: map[string]string{repo: head}} } + +func applyS(ix *Indexer, ctx context.Context, rev uint64, s srcT) error { + a := meshdelivery.SourceAnswer{Branch: "main", Group: s.Group, Revision: int64(rev)} + for r, h := range s.Heads { + a.Repositories = append(a.Repositories, meshdelivery.SourceHead{Repository: r, Head: h, Trunk: "main"}) + } + return ix.ApplySource(ctx, a) } func TestASourceRevisionStampsTheModulesOfTheRepositoriesHeadItNames(t *testing.T) { // 1344/AC1 @@ -25,8 +37,8 @@ func TestASourceRevisionStampsTheModulesOfTheRepositoriesHeadItNames(t *testing. t.Fatalf("module before the source = %+v, want Src 0", before) } // The source names the head merged for the delivery repository, and another repository's head no module of ours is built from. - s := meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{"novox/mesh-delivery": merge, "novox/mesh-catalog": otherMerge}} - if err := ix.ApplySource(ctx, 7, s); err != nil { + s := srcT{Group: "g", Heads: map[string]string{"novox/mesh-delivery": merge, "novox/mesh-catalog": otherMerge}} + if err := applyS(ix, ctx, 7, s); err != nil { t.Fatal(err) } m, ok := moduleOf(t, mods, "a") @@ -39,10 +51,10 @@ func TestASourceRevisionStampsTheModulesOfTheRepositoriesHeadItNames(t *testing. t.Errorf("module c = %+v: written for a head no merge message of the indexer carried", c) } // The same revision again changes nothing, and so does a later one: Src is the first revision naming the commit. - if err := ix.ApplySource(ctx, 7, s); err != nil { + if err := applyS(ix, ctx, 7, s); err != nil { t.Fatal(err) } - if err := ix.ApplySource(ctx, 9, s); err != nil { + if err := applyS(ix, ctx, 9, s); err != nil { t.Fatal(err) } if m, _ := moduleOf(t, mods, "a"); m.Src != 7 { @@ -56,15 +68,15 @@ func TestAMergeHeardAfterTheSourceNamesItsHeadIsStampedWhenWritten(t *testing.T) ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs)) ctx := context.Background() _ = ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree"})) - _ = ix.ApplySource(ctx, 9, sourceOf("novox/mesh-delivery", merge)) - _ = ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", merge)) + _ = applyS(ix, ctx, 9, sourceOf("novox/mesh-delivery", merge)) + _ = applyS(ix, ctx, 7, sourceOf("novox/mesh-delivery", merge)) if m, _ := moduleOf(t, mods, "a"); m.Src != 9 { t.Errorf("module a Src = %d, want 9", m.Src) } }) ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs)) ctx := context.Background() - if err := ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", merge)); err != nil { + if err := applyS(ix, ctx, 7, sourceOf("novox/mesh-delivery", merge)); err != nil { t.Fatal(err) } if _, ok := moduleOf(t, mods, "a"); ok { @@ -89,7 +101,7 @@ func TestAMergeThatNoSourceRevisionNamesIsWrittenAsToday(t *testing.T) { // 1344 t.Fatalf("module a = %+v (%v), want the merge written with Src 0", m, ok) } // A source revision that names another head of the repository: a merge of the head it does not name keeps Src 0. - if err := ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", otherMerge)); err != nil { + if err := applyS(ix, ctx, 7, sourceOf("novox/mesh-delivery", otherMerge)); err != nil { t.Fatal(err) } if m, _ := moduleOf(t, mods, "a"); m.Src != 0 { diff --git a/modules/mesh-delivery-indexer/internal/index/stale_none2_test.go b/modules/mesh-delivery-indexer/internal/index/stale_none2_test.go index 6ec511d1..e92d94b7 100644 --- a/modules/mesh-delivery-indexer/internal/index/stale_none2_test.go +++ b/modules/mesh-delivery-indexer/internal/index/stale_none2_test.go @@ -10,7 +10,7 @@ import ( // not read. func unreadableAvahi(t *testing.T) string { t.Helper() - raw := strings.Replace(catalogueManifest(t, "avahi"), `"version":"1.0",`, `"version":"1.0", "scope": "everything",`, 1) + raw := strings.Replace(catalogueManifest(t, "avahi"), `"version": "1",`, `"version": "1", "scope": "everything",`, 1) if raw == catalogueManifest(t, "avahi") { t.Fatal("the fixture has no version line") } diff --git a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go index 16eb83ce..17667eef 100644 --- a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go +++ b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go @@ -15,7 +15,6 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" @@ -38,7 +37,7 @@ func renamed(t *testing.T, from, to string) string { if out == raw { t.Fatalf("%s's manifest has no module line", from) } - return out + return strings.Replace(out, `"version": "1",`, `"version": "1.0",`, 1) // the validator reads MAJOR.MINOR only (design 47 G §368) } func standingOn(t *testing.T, raw, base string) string { @@ -150,8 +149,8 @@ func TestAgreedToolchainChangesAGoModulesReasonWithNoMerge(t *testing.T) { // 14 } // The release that last merged avahi: its source revision names avahi's commit, so Src (and FPSrc) are 7. merged, _ := moduleOf(t, mods, "avahi") - src := meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{merged.Repository: merged.Commit}} - if err := ix.ApplySource(context.Background(), 7, src); err != nil { + src := srcT{Group: "g", Heads: map[string]string{merged.Repository: merged.Commit}} + if err := applyS(ix, context.Background(), 7, src); err != nil { t.Fatal(err) } if m, _ := moduleOf(t, mods, "avahi"); m.Src != 7 || m.FpSrc != 7 { @@ -183,7 +182,7 @@ func TestAgreedToolchainChangesAGoModulesReasonWithNoMerge(t *testing.T) { // 14 if m.FpSrc != 0 { t.Errorf("a fingerprint moved by a recompute: fp-src %d, want 0", m.FpSrc) } - if err := ix.ApplySource(context.Background(), 8, src); err != nil { + if err := applyS(ix, context.Background(), 8, src); err != nil { t.Fatal(err) } if again, _ := moduleOf(t, mods, "avahi"); again.FpSrc != 0 { @@ -344,7 +343,7 @@ func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { // // verb edges of two modules calling each other, from either side; a third calling one of them keeps its edge. invoking := func(name, invokes string) mod { raw := renamed(t, "avahi", name) - return mod{name, strings.Replace(raw, `"version":"1.0",`, `"version":"1.0", "invokes": [`+invokes+`],`, 1)} + return mod{name, strings.Replace(raw, `"version": "1.0",`, `"version": "1.0", "invokes": [`+invokes+`],`, 1)} } vx, _ := newFormIndexer(t, invoking("caller", `"callee.avahi_status"`), invoking("callee", `"caller.avahi_browse", "*"`), invoking("third", `"callee.avahi_status"`)) diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index 09028dc2..cb7db957 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -130,14 +130,14 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build t.Errorf("the indexer names its bucket %s with %d revisions, its manifest's states are %+v", bucket, history, m.State) } } - for bucket, read := range map[string]string{index.SourceBucket: "mesh-delivery-gate.source", index.PreviewsBucket: "mesh-delivery-checker.previews", + for bucket, read := range map[string]string{index.PreviewsBucket: "mesh-delivery-checker.previews", index.ToolchainsBucket(index.BuildGoModule): "mesh-build-go.toolchains", index.ToolchainsBucket(index.BuildNodeModule): "mesh-build-node.toolchains"} { if !slices.Contains(m.Reads, read) || strings.Replace(read, ".", "_", 1) != bucket { t.Errorf("the indexer reads the bucket %s, its manifest's reads are %v (want %s)", bucket, m.Reads, read) } } // DLV-F1r (ADR 0615 §1): the assigner's assignments is read through its first view and views family, never its state. - if want := []string{"mesh-build-go.toolchains", "mesh-build-node.toolchains", "mesh-delivery-builder.build-log", "mesh-delivery-checker.previews", "mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) { + if want := []string{"mesh-build-go.toolchains", "mesh-build-node.toolchains", "mesh-delivery-builder.build-log", "mesh-delivery-checker.previews"}; !reflect.DeepEqual(sorted(m.Reads), want) { t.Errorf("reads = %v, want %v", m.Reads, want) } // "Reads FORGE" is a consumes of the indexer's stream: the broker grants only buckets by reads (mesh-control @@ -145,7 +145,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build // The registry seats' published and the builder's numbered fill each package's series (DLV-I5, design 51 *The versions*). if want := []string{"go-package-registry.published", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported", "mesh-delivery-assigner.views.assignments", "mesh-delivery-builder.version.numbered", "mesh-delivery-checker.preview.written", "mesh-delivery-checker.try.ended", "mesh-delivery-checker.try.started", "mesh-delivery-checker.verdict", "mesh-delivery-forge-bridge.forge.**", - "node-engine.manifest-reads-changed", "npm-package-registry.published"}; !reflect.DeepEqual(sorted(m.Consumes), want) { // ADR 0632 §2: the counted engines' reads + "mesh-delivery.source-moved", "node-engine.manifest-reads-changed", "npm-package-registry.published"}; !reflect.DeepEqual(sorted(m.Consumes), want) { // ADR 0632 §2: the counted engines' reads t.Errorf("consumes = %v, want %v", m.Consumes, want) } if b, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 || b != "${dir:state}/broker" { @@ -157,7 +157,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build // Three invokes: the file of a registered module's manifest and lock files (registered.go), the forge's trees (the named paths' objects, since the indexer is the one reader of a manifest's paths: // ADR 0499 clauses 2 and 3, ADR 0495 clause 1; and the module directories of the trunk, issue 1900) and whether a // commit is in another (issue 1900). - if want := []string{"mesh-delivery-assigner.assignments", "gitea.gitea_commit_trees", "gitea.gitea_contains", "gitea.gitea_get_file", "gitea.gitea_open_pulls", "mesh-delivery-checker.checker_tries", "mesh-delivery-forge-bridge.bridge_merged_message", "seat:node-engine.composition", "seat:node-engine.manifest-reads", "seat:mesh-hand-acts.hand-act", "seat:mesh-watchdogs.presence"}; !reflect.DeepEqual(m.Invokes, want) { + if want := []string{"mesh-delivery-assigner.assignments", "gitea.gitea_commit_trees", "gitea.gitea_contains", "gitea.gitea_get_file", "gitea.gitea_open_pulls", "mesh-delivery-checker.checker_tries", "mesh-delivery-forge-bridge.bridge_merged_message", "seat:node-engine.composition", "seat:node-engine.manifest-reads", "seat:mesh-delivery.source", "seat:mesh-hand-acts.hand-act", "seat:mesh-watchdogs.presence"}; !reflect.DeepEqual(m.Invokes, want) { t.Errorf("invokes = %v, want %v", m.Invokes, want) } // 1945: indexer_take_in, served by its tools bundle through the takeins state; the process serves none. The same bundle diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index c820abfc..0392d211 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -69,6 +69,7 @@ "mesh-delivery-checker.try.started", "mesh-delivery-checker.verdict", "mesh-delivery-forge-bridge.forge.**", + "mesh-delivery.source-moved", "node-engine.manifest-reads-changed", "npm-package-registry.published" ], @@ -118,8 +119,7 @@ "mesh-build-go.toolchains", "mesh-build-node.toolchains", "mesh-delivery-builder.build-log", - "mesh-delivery-checker.previews", - "mesh-delivery-gate.source" + "mesh-delivery-checker.previews" ], "invokes": [ "mesh-delivery-assigner.assignments", @@ -131,6 +131,7 @@ "mesh-delivery-forge-bridge.bridge_merged_message", "seat:node-engine.composition", "seat:node-engine.manifest-reads", + "seat:mesh-delivery.source", "seat:mesh-hand-acts.hand-act", "seat:mesh-watchdogs.presence" ], From 496989ccfb485488bf9a6c42d61d0409ad1d2adc Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 11:43:53 +0100 Subject: [PATCH 9/9] CT10: meshcatalogclient follows the registrations, a first view then each registration-changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assigner and the forge module's merge switch on their first registrations (design 47 G §353), so the seat's client carries the typed follower on go/views: a first view asked of the verb registrations, a newer version heard asked again until the picture holds it, an answer read before the change asked once more. Builds: design 47 G §353, §245 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- contracts/go/clients/meshcatalogclient/go.mod | 9 + contracts/go/clients/meshcatalogclient/go.sum | 26 ++- .../meshcatalogclient/registrations.go | 200 ++++++++++++++++++ .../meshcatalogclient/registrations_test.go | 140 ++++++++++++ 4 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 contracts/go/clients/meshcatalogclient/registrations.go create mode 100644 contracts/go/clients/meshcatalogclient/registrations_test.go diff --git a/contracts/go/clients/meshcatalogclient/go.mod b/contracts/go/clients/meshcatalogclient/go.mod index 450b54a2..433b7918 100644 --- a/contracts/go/clients/meshcatalogclient/go.mod +++ b/contracts/go/clients/meshcatalogclient/go.mod @@ -6,3 +6,12 @@ require ( git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 ) + +require ( + github.com/klauspost/compress v1.20.0 // indirect + github.com/nats-io/nats.go v1.54.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/contracts/go/clients/meshcatalogclient/go.sum b/contracts/go/clients/meshcatalogclient/go.sum index 0daef11f..48737795 100644 --- a/contracts/go/clients/meshcatalogclient/go.sum +++ b/contracts/go/clients/meshcatalogclient/go.sum @@ -1,12 +1,26 @@ -github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= -github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= -github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= -github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= diff --git a/contracts/go/clients/meshcatalogclient/registrations.go b/contracts/go/clients/meshcatalogclient/registrations.go new file mode 100644 index 00000000..d58aaf0e --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/registrations.go @@ -0,0 +1,200 @@ +package meshcatalogclient + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/buscalls" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// RegistrationsVerb is the seat verb a part takes its first view of the registrations by, as its manifest's invokes +// names it. +const RegistrationsVerb = "seat:" + seat.Seat + "." + seat.VerbRegistrations + +// Call asks one seat verb over the bus and answers what the holder said, whatever wraps it (process.Conn.Call, or +// stdio.Ask through the tool runner). +type Call func(key string, body any) (json.RawMessage, error) + +// registrationsCreated stands for the picture's Created in its follower: the registrations are followed by the seat's +// event, never by an owner's views family, so one fixed time, never zero. +var registrationsCreated = time.Unix(0, 0).UTC() + +// Registrations is a part's typed follower of the registrations (design 47 G §353): a first view asked of the seat's verb +// `registrations`, then each `registration-changed` heard, built on go/views' Follower, so the runtime's tools +// `_first_views` and `_first_view_again` read and steer it as every first view. Its picture is keyed by +// module, each value the module's newest registration. Ready is closed at the first view: a part that switches on its +// first registrations waits for it. +type Registrations struct { + Follower *views.Follower + + call Call +} + +// NewRegistrations is a Registrations asking the seat through call. Nothing is asked before Start. +func NewRegistrations(call Call) *Registrations { + r := &Registrations{call: call} + r.Follower = &views.Follower{Owner: seat.Seat, Tool: RegistrationsVerb, State: seat.StateRegistrations, Ask: r.ask} + return r +} + +// WireRegistrations is a Registrations adopted by a stdio part's views.Reader: the reader writes its conditions, reads it +// as `mesh-catalog.registrations`, lists and asks it again in its first-view tools, and at its Start subscribes the +// seat's event registration-changed (the part's manifest consumes it) into Hear and asks the first view. changed, when +// not nil, is told of each module whose registration the picture changes, after the reader's own. The part declares the +// seat's verb registrations in invokes. +func WireRegistrations(r *views.Reader, call Call, changed func(reg seat.Registration, gone bool)) (*Registrations, error) { + g := NewRegistrations(call) + g.Follower.Changed = typedChanged(changed) + if err := r.Adopt(g.Follower, seat.Seat, seat.EventRegistrationChanged, g.Hear); err != nil { + return nil, err + } + return g, nil +} + +// WireRegistrationsProcess is WireRegistrations for a process's views.ProcessReader: its picture +// (`mesh-catalog_registrations`) reads the registrations as a read-only bucket, and the seat's event +// registration-changed on the module's durable consumer goes to Hear. +func WireRegistrationsProcess(r *views.ProcessReader, call Call, changed func(reg seat.Registration, gone bool)) (*Registrations, *views.Picture, error) { + g := NewRegistrations(call) + g.Follower.Changed = typedChanged(changed) + p, err := r.Adopt(g.Follower, seat.Seat, seat.EventRegistrationChanged, g.Hear) + if err != nil { + return nil, nil, err + } + return g, p, nil +} + +func typedChanged(changed func(seat.Registration, bool)) func(string, json.RawMessage, bool, time.Time) { + if changed == nil { + return nil + } + return func(module string, value json.RawMessage, gone bool, _ time.Time) { + reg := seat.Registration{Module: module} + if !gone { + _ = json.Unmarshal(value, ®) + } + changed(reg, gone) + } +} + +// Start asks the first view until it is answered or ctx ends. +func (r *Registrations) Start(ctx context.Context) error { return r.Follower.Start(ctx) } + +// Ready is closed once the first view is answered. +func (r *Registrations) Ready() <-chan struct{} { return r.Follower.Ready() } + +// Registration is one module's newest registration in the picture. +func (r *Registrations) Registration(module string) (seat.Registration, bool) { + raw, ok := r.Follower.Get(module) + if !ok { + return seat.Registration{}, false + } + var reg seat.Registration + if err := json.Unmarshal(raw, ®); err != nil { + return seat.Registration{}, false + } + return reg, true +} + +// All is every registration in the picture, by module name. +func (r *Registrations) All() []seat.Registration { + values := r.Follower.Values() + out := make([]seat.Registration, 0, len(values)) + for _, raw := range values { + var reg seat.Registration + if json.Unmarshal(raw, ®) == nil { + out = append(out, reg) + } + } + slices.SortFunc(out, func(a, b seat.Registration) int { + if a.Module < b.Module { + return -1 + } + if a.Module > b.Module { + return 1 + } + return 0 + }) + return out +} + +// Hear takes one `registration-changed` event, as the part's durable consumer of the seat's events hands it. A version +// the picture holds, or one older, is passed over; a newer one asks the first view again on its own goroutine, so the +// consumer is never held while the holder is away, and again until the picture holds that version or ctx ends (an ask +// already running, the first view among them, may have been answered before the change was written). Data that is no such event is an error. +func (r *Registrations) Hear(ctx context.Context, data []byte) error { + var ev seat.RegistrationChangedEvent + if err := json.Unmarshal(data, &ev); err != nil || ev.Module == "" || ev.Version <= 0 { + return fmt.Errorf("meshcatalogclient: not a %s event: %s", seat.EventRegistrationChanged, clipBytes(data)) + } + if r.holds(ev.Module, ev.Version) { + return nil + } + go r.catchUp(ctx, ev.Module, ev.Version) + return nil +} + +func (r *Registrations) holds(module string, version int64) bool { + reg, ok := r.Registration(module) + return ok && reg.Version >= version +} + +// catchUp asks the first view again until the picture holds version of module, pausing between asks as the follower's +// Wait says (doubling to a minute), or until ctx ends. +func (r *Registrations) catchUp(ctx context.Context, module string, version int64) { + wait := r.Follower.Wait + if wait == 0 { + wait = 5 * time.Second + } + for !r.holds(module, version) { + if err := r.Follower.Gap(ctx, fmt.Sprintf("registration of %s at version %d said", module, version)); err != nil { + return + } + if r.holds(module, version) { + return + } + p, cancel := context.WithTimeout(ctx, wait) + <-p.Done() + cancel() + if ctx.Err() != nil { + return + } + if wait *= 2; wait > time.Minute { + wait = time.Minute + } + } +} + +// ask is the Follower's Ask: every registration, whole in one answer. +func (r *Registrations) ask(_ context.Context, _ string) (views.Answer, error) { + raw, err := r.call(RegistrationsVerb, seat.RegistrationsInput{}) + if err != nil { + return views.Answer{}, err + } + answer, said, ok := buscalls.Unwrap(raw) + if !ok { + return views.Answer{}, fmt.Errorf("%s answered: %s", RegistrationsVerb, said) + } + var a seat.RegistrationsAnswer + if err := json.Unmarshal(answer, &a); err != nil || a.Registrations == nil { + return views.Answer{}, fmt.Errorf("%s answered no registrations: %s", RegistrationsVerb, clipBytes(answer)) + } + values := make(map[string]json.RawMessage, len(a.Registrations)) + for _, reg := range a.Registrations { + v, _ := json.Marshal(reg) + values[reg.Module] = v + } + return views.Answer{V: 1, Owner: seat.Seat, State: seat.StateRegistrations, Created: registrationsCreated, Values: values, At: time.Now()}, nil +} + +func clipBytes(b []byte) string { + if len(b) > 200 { + return string(b[:200]) + "…" + } + return string(b) +} diff --git a/contracts/go/clients/meshcatalogclient/registrations_test.go b/contracts/go/clients/meshcatalogclient/registrations_test.go new file mode 100644 index 00000000..66ff2546 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/registrations_test.go @@ -0,0 +1,140 @@ +package meshcatalogclient + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" +) + +// fakeRegistrations answers the verb registrations from the versions it keeps, and counts each ask. +type fakeRegistrations struct { + mu sync.Mutex + versions map[string]int64 + asks int + // stale answers the next ask from these versions, once: an answer read before a change was written. + stale map[string]int64 +} + +func (f *fakeRegistrations) call(key string, body any) (json.RawMessage, error) { + if key != RegistrationsVerb { + return nil, fmt.Errorf("asked %s", key) + } + if in := body.(seat.RegistrationsInput); in.Module != "" || in.History { + return nil, fmt.Errorf("asked %+v", in) + } + f.mu.Lock() + defer f.mu.Unlock() + f.asks++ + versions := f.versions + if f.stale != nil { + versions, f.stale = f.stale, nil + } + a := seat.RegistrationsAnswer{Registrations: []seat.Registration{}} + for m, v := range versions { + a.Registrations = append(a.Registrations, seat.Registration{Module: m, Version: v, Origin: "novox/" + m, Path: "module.json", Branches: []string{"main"}}) + } + return json.Marshal(a) +} + +func (f *fakeRegistrations) set(module string, version int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.versions[module] = version +} + +func (f *fakeRegistrations) asked() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.asks +} + +func changedEvent(module string, version int64) []byte { + b, _ := json.Marshal(seat.RegistrationChangedEvent{V: 1, Module: module, Version: version, Moved: []string{"branches"}, Warrant: "ask-1"}) + return b +} + +// CT10 (design 47 G §353): a part following the registrations takes a first view of the verb registrations, then each +// registration-changed: a newer version is asked and held, one it holds asks nothing, a change heard while an answer +// read before it was given asks again, and what is no such event is refused. +func TestRegistrationsAreAFirstViewThenTheirEvents(t *testing.T) { + changed := make(chan seat.Registration, 16) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + next := func() seat.Registration { + t.Helper() + select { + case reg := <-changed: + return reg + case <-ctx.Done(): + t.Fatal("no registration changed") + return seat.Registration{} + } + } + f := &fakeRegistrations{versions: map[string]int64{"nats": 1, "zsh": 3}} + r := NewRegistrations(f.call) + r.Follower.Wait = time.Nanosecond + r.Follower.Changed = typedChanged(func(reg seat.Registration, gone bool) { + if !gone { + changed <- reg + } + }) + select { + case <-r.Ready(): + t.Fatal("ready before its first view") + default: + } + if err := r.Start(ctx); err != nil { + t.Fatal(err) + } + <-r.Ready() + if reg, ok := r.Registration("nats"); !ok || reg.Version != 1 || reg.Origin != "novox/nats" { + t.Fatalf("first view holds nats as %+v, %v", reg, ok) + } + if all := r.All(); len(all) != 2 || all[0].Module != "nats" || all[1].Module != "zsh" { + t.Fatalf("All = %+v", all) + } + for range 2 { + next() + } + + if err := r.Hear(ctx, changedEvent("zsh", 3)); err != nil { + t.Fatal(err) + } + if n := f.asked(); n != 1 { + t.Errorf("a version held asked the first view again: %d asks", n) + } + + f.set("nats", 2) + if err := r.Hear(ctx, changedEvent("nats", 2)); err != nil { + t.Fatal(err) + } + if reg := next(); reg.Module != "nats" || reg.Version != 2 { + t.Fatalf("after registration-changed: %+v", reg) + } + + // The answer to the ask the event starts was read before the change was written: it is asked once more. + f.mu.Lock() + f.stale = map[string]int64{"nats": 2, "zsh": 3} + f.mu.Unlock() + f.set("zsh", 4) + if err := r.Hear(ctx, changedEvent("zsh", 4)); err != nil { + t.Fatal(err) + } + if reg := next(); reg.Module != "zsh" || reg.Version != 4 { + t.Fatalf("after a stale answer: %+v", reg) + } + if n := f.asked(); n != 4 { + t.Errorf("asks = %d, want 4 (first view, nats, a stale answer and the one after)", n) + } + + for _, data := range []string{`{}`, `not json`, `{"module":"nats"}`} { + if err := r.Hear(ctx, []byte(data)); err == nil { + t.Errorf("Hear(%s) took it", data) + } + } +}