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) + } + } +} 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 */ 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..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 @@ -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") @@ -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/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/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/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/modules_test.go b/modules/mesh-delivery-indexer/internal/index/modules_test.go index 1381e39b..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" @@ -82,7 +81,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} } @@ -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 { @@ -515,7 +514,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/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/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/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 4e1cd9ee..c2437640 100644 --- a/modules/mesh-delivery-indexer/internal/index/registered_test.go +++ b/modules/mesh-delivery-indexer/internal/index/registered_test.go @@ -9,13 +9,12 @@ 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" ) 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 +40,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 +150,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 +201,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 +220,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"}, @@ -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/toolchains_test.go b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go index 762649d9..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",`, `"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 +504,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 09d6cf52..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) } @@ -358,20 +358,24 @@ 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. - 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) + // 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. + // 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.0","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"}) { - 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"}]}`) + 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) } @@ -383,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) } @@ -399,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) } @@ -411,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}) @@ -446,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) } @@ -509,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) @@ -518,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 0eaf20e8..4dbd39c0 100644 --- a/modules/mesh-delivery-indexer/judge/judge_test.go +++ b/modules/mesh-delivery-indexer/judge/judge_test.go @@ -20,27 +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"`}}, - {name: "a manifest under another name", raws: map[string]string{"b": `{"module":"a","version":"1"}`}, + // 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.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.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{} @@ -64,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 { @@ -79,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": {}} @@ -100,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 || @@ -113,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) @@ -134,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/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. 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 113c1610..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) } @@ -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 { @@ -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) @@ -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) } @@ -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":[ 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" ], 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/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/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/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..3a618fe0 --- /dev/null +++ b/modules/mesh-delivery-pipeline/module.json @@ -0,0 +1,137 @@ +{ + "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" + ] + }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-pipeline-tools", + "binary": "mesh-delivery-pipeline-tools", + "loads": [ + "mesh-delivery-pipeline-tools" + ] + } + ] + } +}