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",