From 333ab9de22c2a109b12b330336c001db44bfb58b Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 16:17:44 +0100 Subject: [PATCH] A provider's have counts as its outcome only from a build whose manifest offers the provision (ADR 0603 clause 5) A provider whose build does not offer the provision it was asked for must not be counted as having delivered it; the provisioner reads the build's manifest (the builds read serves only this offer check and makes no plan, ADR 0593 section 8) before it takes the provider's have as the outcome. --- .../cmd/mesh-provisioner/holder.go | 63 ++++++++++++++++++- .../holder_integration_test.go | 28 +++++++++ .../cmd/mesh-provisioner/holder_test.go | 22 +++++++ .../cmd/mesh-provisioner/provisioner_test.go | 9 +-- modules/mesh-provisioner/module.json | 1 + 5 files changed, 118 insertions(+), 5 deletions(-) diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go index 4a63e956..5d8fa5dc 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go @@ -68,7 +68,10 @@ const ( ModulesRead = delivery.IndexerModule + ".modules" ManifestsRead = delivery.IndexerModule + ".manifests" HaveRead = "node-engine.have" - ComposedRead = "mesh-host.composed" + // BuildsRead is the builder's builds, `.`: the key of the build a provider runs names the + // digest of the manifest it was built from (novox/hq ADR 0603 clause 5). + BuildsRead = delivery.BuilderModule + ".builds" + ComposedRead = "mesh-host.composed" ) // BoundReads are the providers' entries this module's manifest reads, `.bound`, whose changes it hears. Which @@ -819,6 +822,14 @@ func (h *Holder) haveOutcome(p *provisions.Provision) { case have.Phase == delivery.PhaseFailed || have.Health == delivery.HealthFailed: p.State, p.Why = provisions.Failed, orSaid(have.Detail, where+" failed") case have.Phase == delivery.PhaseRunning && have.Health == delivery.HealthOK && (wantFP == "" || have.FP == wantFP): + // A provider's have counts as its outcome only from a build whose manifest offers the provision (ADR 0603 clause 5). + // Refused only when that manifest is known and does not offer it: a have with no builder key (an old-form build, an + // adopted module) or an unreadable manifest keeps the outcome it had. + if offers, known := h.buildOffers(p.Provider.Module, have.FP, p.Provision); known && !offers { + p.State, p.Why = provisions.Asked, "waits for "+where+" to run a build that offers "+p.Provision+": the build it runs ("+ + have.FP+") does not" + return + } p.State, p.Why = provisions.Provided, "" default: p.State, p.Why = provisions.Asked, fmt.Sprintf("waits for %s to run healthy at the build its want names (it is %s, "+ @@ -826,6 +837,56 @@ func (h *Holder) haveOutcome(p *provisions.Provision) { } } +// buildOffers says whether the build of a module at a fingerprint, as the builder keyed it, was made from a manifest that +// offers the provision; known is false when the builder holds no such key, the key names no manifest, or the indexer holds +// no such manifest (the caller then does not refuse). +func (h *Holder) buildOffers(module, fingerprint, provision string) (offers, known bool) { + key, err := delivery.BuilderBuildKey(module, fingerprint) + if err != nil { + return false, false + } + be, err := h.Store.Get(BuildsRead, key) + if err != nil || be == nil { + return false, false + } + b, err := delivery.DecodeBuilderBuild(be.Value) + if err != nil || b.Manifest == "" { + return false, false + } + mk, err := delivery.ManifestsKey(b.Manifest) + if err != nil { + return false, false + } + me, err := h.Store.Get(ManifestsRead, mk) + if err != nil || me == nil { + return false, false + } + man, err := delivery.DecodeManifest(me.Value) + if err != nil { + return false, false + } + var decl struct { + Provides []json.RawMessage `json:"provides"` + } + if json.Unmarshal([]byte(man.JSON), &decl) != nil { + return false, false + } + for _, raw := range decl.Provides { + var name string + if json.Unmarshal(raw, &name) != nil { + var full struct { + Name string `json:"name"` + } + _ = json.Unmarshal(raw, &full) + name = full.Name + } + if name == provision { + return true, true + } + } + return false, true +} + // keepsEntries says whether a provider's outcome for one provision is its entries (its manifest at the trunk declares the // state `bound`) rather than its own have. When any offer of the provider says `answered-by: helper`, only those offers // are answered by its entries: a module running the helper for one provision and making another some other way (gitea: diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go index 6ab8f93e..91e53cc2 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go @@ -224,6 +224,34 @@ func TestTheHolderPlansProvidesAndAsksOnTheBus(t *testing.T) { t.Fatal(err) } settled(HaveRead, node+".postgres") + // The running build's manifest, as the builder keyed it: one that does not offer the provision keeps the key asked + // (ADR 0603 clause 5), one that does lets the have count. + buildsKey := mustKey(delivery.BuilderBuildKey("postgres", fpsOf["postgres"])) + offered := func(provides string) { + raw := `{"module":"postgres","version":"1","provides":` + provides + `}` + digest := delivery.ManifestDigest([]byte(raw)) + value, _ := delivery.EncodeManifest(delivery.Manifest{Module: "postgres", JSON: raw}) + if err := s.Put(ManifestsRead, mustKey(delivery.ManifestsKey(digest)), json.RawMessage(value)); err != nil { + t.Fatal(err) + } + build, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest}) + if err != nil { + t.Fatal(err) + } + if err := s.Put(BuildsRead, buildsKey, json.RawMessage(build)); err != nil { + t.Fatal(err) + } + } + offered(`["other"]`) + for i := 0; i < 2; i++ { + if _, err := h.ComposeNode(ctx, node); err != nil { + t.Fatal(err) + } + } + if p := state(key); p.State != provisions.Asked { + t.Fatalf("a build not offering the provision turned it %s", p.State) + } + offered(`["postgres-database"]`) for i := 0; i < 2; i++ { // the credential judged first, then what depends on it if _, err := h.ComposeNode(ctx, node); err != nil { t.Fatal(err) diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go index 63504236..f99f945a 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go @@ -189,6 +189,28 @@ func TestAKeysStateFollowsItsProvidersOutcome(t *testing.T) { t.Errorf("the provider's have %s: %+v, want %s", row.have, p, row.state) } } + // A have counts as the provider's outcome only from a build whose manifest offers the provision (ADR 0603 clause 5): + // refused when the build's manifest is known and does not offer it, provided when it does. + bfp := "src2:" + strings.Repeat("bb", 32) + buildKey := mustKey(delivery.BuilderBuildKey("systemd-resolved", bfp)) + for _, row := range []struct{ name, provides, state, why string }{ + {"a build whose manifest does not offer it", `["other"]`, provisions.Asked, "offers split-dns"}, + {"a build whose manifest offers it", `[{"name":"split-dns"}]`, provisions.Provided, ""}, + } { + raw := `{"module":"systemd-resolved","version":"1","provides":` + row.provides + `}` + digest := delivery.ManifestDigest([]byte(raw)) + value, _ := delivery.EncodeManifest(delivery.Manifest{Module: "systemd-resolved", JSON: raw}) + _ = m.s.Put(ManifestsRead, mustKey(delivery.ManifestsKey(digest)), json.RawMessage(value)) + build, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest}) + if err != nil { + t.Fatal(err) + } + _ = m.s.Put(BuildsRead, buildKey, json.RawMessage(build)) + m.compose(t, "g14") + if p := m.state(t, split); p.State != row.state || !strings.Contains(p.Why, row.why) { + t.Errorf("%s: %+v, want %s", row.name, p, row.state) + } + } if e, _ := m.s.Get(PlansState, "g14.gitea"); e == nil { t.Error("the plan is not written") } diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go index 7ef2233a..3b02e089 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go @@ -9,7 +9,6 @@ import ( "sync" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // memStore is the bus as mesh-provisioner reaches it, in memory: the states it keeps. @@ -326,13 +325,15 @@ func TestTheManifestDeclaresWhatTheCodeKeeps(t *testing.T) { t.Errorf("the manifest lists %s, which is not served", tool) } } - for _, r := range append(slices.Clone(WatchedReads), ManifestsRead) { + // The builds read serves only haveOutcome's offer check (ADR 0603 clause 5: which manifest the build a provider runs + // was made from) and makes no plan, so ADR 0593 §8 ("it composes nothing") holds. + for _, r := range append(slices.Clone(WatchedReads), ManifestsRead, BuildsRead) { if !slices.Contains(m.Reads, r) { t.Errorf("the manifest does not read %s, which the holder reads", r) } } - // It composes nothing (ADR 0593 §8): no build, and no config value, is read to make a plan. - for _, r := range []string{delivery.BuilderModule + ".builds", "mesh-config.resolved"} { + // It composes nothing (ADR 0593 §8): no config value is read to make a plan. + for _, r := range []string{"mesh-config.resolved"} { if slices.Contains(m.Reads, r) { t.Errorf("the manifest reads %s, which only a composer reads", r) } diff --git a/modules/mesh-provisioner/module.json b/modules/mesh-provisioner/module.json index 7d5c4d3e..7b512a6f 100644 --- a/modules/mesh-provisioner/module.json +++ b/modules/mesh-provisioner/module.json @@ -20,6 +20,7 @@ "mesh-delivery-indexer.modules", "mesh-delivery-indexer.manifests", "node-engine.have", + "mesh-delivery-builder.builds", "mesh-host.composed", "gitea.bound", "keycloak.bound",