diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/compose_bench_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/compose_bench_test.go deleted file mode 100644 index 0b901b32..00000000 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/compose_bench_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package main - -// BenchmarkComposeNode times ComposeNode of an unchanged node over a mesh captured from the bus (issue 1954): the -// placement, wants, haves, the indexer's modules and manifests, the builder's builds, mesh-config's resolved values and -// the templates archives, read with the read tools (mesh-placement.placement, mesh-delivery-view.assigner_wants, -// node_haves, indexer_modules, builder_build, mesh-config.config) and the manifests and archives by digest. The capture -// is not kept in this repository (a few megabytes, and the mesh's own addresses and keys): the benchmark reads the -// directory MESH_COMPOSE_CAPTURE names and is skipped without it. MESH_COMPOSE_NODE names the node (novox when unset). -// -// The capture's layout: placement.json, wants.json, haves.json, modules.json and config.json as those tools answer, -// builds/..json as builder_build answers, manifests/ and archives/ as their bytes. - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" - "git.novox.be/novox/mesh-sdk/go/compose/fromruntime" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// countingStore counts the holder's reads of its own states (and the providers' entries), each a bus request live. -type countingStore struct { - Store - gets, keys atomic.Int64 -} - -func (c *countingStore) Get(state, key string) (*Entry, error) { - c.gets.Add(1) - return c.Store.Get(state, key) -} - -func (c *countingStore) Keys(state string) ([]string, error) { - c.keys.Add(1) - return c.Store.Keys(state) -} - -// captured is the captured mesh as the states the holder reads, and the archives by digest. -func captured(b *testing.B, dir string) (map[string]map[string][]byte, map[string][]byte) { - b.Helper() - read := func(name string, into any) { - raw, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - b.Fatal(err) - } - if err := json.Unmarshal(raw, into); err != nil { - b.Fatalf("%s: %v", name, err) - } - } - states := map[string]map[string][]byte{} - put := func(state, key string, raw []byte) { - if states[state] == nil { - states[state] = map[string][]byte{} - } - states[state][key] = raw - } - var placement struct { - Keys map[string]struct { - Placement json.RawMessage `json:"placement"` - } `json:"keys"` - } - read("placement.json", &placement) - for node, k := range placement.Keys { - var p delivery.Placement - if err := json.Unmarshal(k.Placement, &p); err != nil { - b.Fatal(err) - } - p.Node = node - raw, err := delivery.EncodePlacement(p) - if err != nil { - b.Fatal(err) - } - put(PlacementRead, "placement."+node, raw) - } - var wants struct { - Wants map[string]json.RawMessage `json:"wants"` - } - read("wants.json", &wants) - for k, v := range wants.Wants { - put(WantsRead, k, v) - } - var haves struct { - Keys map[string]json.RawMessage `json:"keys"` - } - read("haves.json", &haves) - for k, v := range haves.Keys { - put(HaveRead, k, v) - } - var modules struct { - Modules map[string]json.RawMessage `json:"modules"` - } - read("modules.json", &modules) - for m, v := range modules.Modules { - key, err := delivery.ModulesKey(m) - if err != nil { - b.Fatal(err) - } - put(ModulesRead, key, v) - } - var config struct { - Resolved []struct { - Key, Module, Node, Source string - Value any - } `json:"resolved"` - } - read("config.json", &config) - for _, r := range config.Resolved { - raw, _ := json.Marshal(map[string]any{"value": r.Value, "source": r.Source}) - put(ResolvedRead, r.Node+"."+r.Module+"."+r.Key, raw) - } - manifests, _ := filepath.Glob(filepath.Join(dir, "manifests", "*")) - for _, f := range manifests { - raw, err := os.ReadFile(f) - if err != nil { - b.Fatal(err) - } - digest := filepath.Base(f) - v, err := delivery.EncodeManifest(delivery.Manifest{Module: "captured", JSON: string(raw)}) - if err != nil { - b.Fatal(err) - } - key, err := delivery.ManifestsKey(digest) - if err != nil { - b.Fatal(err) - } - put(ManifestsRead, key, v) - } - builds, _ := filepath.Glob(filepath.Join(dir, "builds", "*.json")) - for _, f := range builds { - var bb struct { - Module string `json:"module"` - Fingerprint string `json:"fingerprint"` - Build json.RawMessage `json:"build"` - } - read(strings.TrimPrefix(f, dir+"/"), &bb) - key, err := delivery.BuilderBuildKey(bb.Module, bb.Fingerprint) - if err != nil { - b.Fatal(err) - } - put(BuildsRead, key, bb.Build) - } - archives := map[string][]byte{} - files, _ := filepath.Glob(filepath.Join(dir, "archives", "*")) - for _, f := range files { - raw, err := os.ReadFile(f) - if err != nil { - b.Fatal(err) - } - archives[filepath.Base(f)] = raw - } - return states, archives -} - -func BenchmarkComposeNode(b *testing.B) { - dir := os.Getenv("MESH_COMPOSE_CAPTURE") - if dir == "" { - b.Skip("MESH_COMPOSE_CAPTURE names no captured mesh") - } - node := os.Getenv("MESH_COMPOSE_NODE") - if node == "" { - node = "novox" - } - states, archives := captured(b, dir) - ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error { - for k, v := range states[state] { - if err := handle(stdio.StateChange{State: state, Key: k, Op: stdio.OpPut, Value: v, Current: true}); err != nil { - return err - } - } - return nil - }, func(state, key string) ([]byte, bool, error) { - v, ok := states[state][key] - return v, ok, nil - }) - fetch := fromruntime.KeptFetch(func(_ context.Context, url string) ([]byte, error) { - _, hex, _ := strings.Cut(url, "sha256:") - raw, ok := archives[hex] - if !ok { - return nil, os.ErrNotExist - } - return raw, nil - }) - own := &countingStore{Store: newStore()} - h := &Holder{Store: Watching(own, ws), Compose: composerOver(frombus.StatesReader(fromruntime.Watched{WatchedStates: ws}, fetch)), - Now: time.Now, Logf: func(string, ...any) {}} - // Composed twice before it is timed: a provision's state follows the states of those it depends on, which the first - // composition writes, so the second may still change a key; from then on nothing changes. - first, err := h.ComposeNode(context.Background(), node) - if err != nil { - b.Fatal(err) - } - if second, err := h.ComposeNode(context.Background(), node); err != nil { - b.Fatal(err) - } else if len(second.Changed) > 0 { - b.Logf("the second composition of %s changed %v", node, second.Changed) - } - b.Logf("first composition of %s: %d plans written, %d held, %d problems, %d keys changed", node, len(first.Plans), - len(first.Held), len(first.Problems), len(first.Changed)) - for _, p := range first.Problems { - b.Logf("problem: %s", p) - } - own.gets.Store(0) - own.keys.Store(0) - b.ResetTimer() - var c Composed - for b.Loop() { - c, err = h.ComposeNode(context.Background(), node) - if err != nil { - b.Fatal(err) - } - if len(c.Plans)+len(c.Gone)+len(c.Changed) > 0 { - b.Fatalf("an unchanged node composed changes: %+v", c) - } - } - b.Logf("the last composition of %s: %s", node, TookSaid(c.Took)) - b.ReportMetric(float64(own.gets.Load())/float64(b.N), "own-gets/op") - b.ReportMetric(float64(own.keys.Load())/float64(b.N), "own-keys/op") -} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/composed_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/composed_test.go new file mode 100644 index 00000000..88eeaafd --- /dev/null +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/composed_test.go @@ -0,0 +1,204 @@ +package main + +// The provisioner makes every plan from the provisions the node-engine's published compositions carry (novox/hq ADR +// 0593 §6, §8), with the plans and provisions it wrote when it composed them itself. +// +// The fixtures were captured on 2026-11-02 from the holder as it stood before (frombus.PlansHeldTwice over mesh-sdk's +// frombustest.Provisioned mesh: restic on n1 requiring postgres-database from postgres there, the vault on n2): +// testdata/today-composed.json is what that composition answered at the node's wants and at its newest builds, +// testdata/today-states.golden.json what the holder then wrote. "ahead-" has restic wanted at an older build +// that requires nothing, so its key is ahead of its want and carries its plan entry (issue 1126 review 1). + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// today is a captured composition: the node, the builds its wants named, and the plans at them and at the newest. +type today struct { + Node string `json:"node"` + KeptAt map[string]string `json:"kept-at"` + AtWants struct { + Plans map[string]catalogue.Plan `json:"Plans"` + Held map[string]string `json:"Held"` + } `json:"at-wants"` + Newest struct { + Plans map[string]catalogue.Plan `json:"Plans"` + } `json:"newest"` +} + +// fpsOf is frombustest's modules and the fingerprints they are built at (frombustest.FPs). +var fpsOf = map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64), + "store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)} + +// published puts on the store what n1's engine publishes for the captured case: each placed module at its newest done +// build, and each wanted one at its want, each key carrying its plan. +func published(t *testing.T, s *memStore, c today) { + t.Helper() + put := func(module, fp string, plan catalogue.Plan, has bool) { + v := map[string]any{"v": 1, "node": c.Node, "module": module, "fp": fp, "resources": []any{}} + if has { + v["plan"] = plan + } + raw, _ := json.Marshal(v) + s.write(t, ComposedRead, mustKey(delivery.ComposedKey(c.Node, module, fp)), string(raw)) + } + for module, fp := range fpsOf { + plan, has := c.Newest.Plans[module] + put(module, fp, plan, has) + } + for module, fp := range c.KeptAt { + plan, has := c.AtWants.Plans[module] + put(module, fp, plan, has) + } + s.write(t, ComposedRead, mustKey(delivery.ComposedNodeKey(c.Node)), `{"v":1,"node":"`+c.Node+`","resources":[]}`) +} + +// goldenMesh is the store as the provisioner reads it for frombustest's mesh: the indexer's modules, n1's placement, the +// vault's on n2, restic's want. +func goldenMesh(t *testing.T, c today) *memStore { + t.Helper() + s := newStore() + for module, fp := range fpsOf { + // A provider's manifest says whether it keeps entries: postgres's, as frombustest has it, keeps none. + raw := `{"module":"` + module + `","version":"1"}` + if module == "postgres" { + raw = `{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` + + `"serves":{"postgres-database":{"port":5432}}}` + } + digest := delivery.ManifestDigest([]byte(raw)) + value, _ := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw}) + s.write(t, ManifestsRead, mustKey(delivery.ManifestsKey(digest)), string(value)) + s.write(t, ModulesRead, module, `{"v":1,"fp2":"`+fp+`","manifest":"`+digest+`"}`) + } + s.write(t, PlacementRead, "placement."+c.Node, `{"v":1,"node":"`+c.Node+`","decided":{},"modules":[{"module":"store"},`+ + `{"module":"postgres"},{"module":"mesh-provisioner-client"},{"module":"restic"}]}`) + s.write(t, PlacementRead, "placement.n2", `{"v":1,"node":"n2","decided":{},"modules":[{"module":"mesh-vault"}]}`) + s.write(t, WantsRead, "want."+c.Node+".restic", `{"v":1,"fp":"`+c.KeptAt["restic"]+`","self":true}`) + return s +} + +// The plans and provisions made from the published compositions are today's, value for value, with a key ahead of its +// want carrying its plan entry; a pass over the same compositions writes nothing. +func TestThePlansFromCompositionsAreTodays(t *testing.T) { + for _, name := range []string{"", "ahead-"} { + t.Run("case "+name, func(t *testing.T) { + var c today + readJSON(t, "testdata/"+name+"today-composed.json", &c) + var golden map[string]map[string]json.RawMessage + readJSON(t, "testdata/"+name+"today-states.golden.json", &golden) + s := goldenMesh(t, c) + published(t, s, c) + now := time.Date(2026, 11, 2, 18, 0, 0, 0, time.UTC) + h := &Holder{Store: s, Asks: &router{}, Logf: t.Logf, Now: func() time.Time { return now }} + h.Compose = FromCompositions(s) + out, err := h.ComposeNode(context.Background(), c.Node) + if err != nil { + t.Fatal(err) + } + if len(out.Problems)+len(out.Held) > 0 { + t.Errorf("problems %v, held %v", out.Problems, out.Held) + } + for state, want := range golden { + keys, _ := s.Keys(state) + if len(keys) != len(want) { + t.Errorf("%s holds %v, today %d keys", state, keys, len(want)) + } + for key, raw := range want { + e, _ := s.Get(state, key) + if e == nil || !sameBytes(t, e.Value, raw) { + t.Errorf("%s %s:\n got %s\nwant %s", state, key, valueOf(e), raw) + } + } + } + again, err := h.ComposeNode(context.Background(), c.Node) + if err != nil || len(again.Plans)+len(again.Gone)+len(again.Changed) > 0 { + t.Errorf("a second pass over the same compositions wrote %+v (%v)", again, err) + } + }) + } +} + +// A composition the engine refused at the wanted build holds its module, its last plan kept and its words in `refused`; +// one the engine has not published yet holds it too, saying so; a module whose newest build is refused keeps its +// plan at its want. +func TestARefusedOrMissingCompositionHoldsItsModule(t *testing.T) { + var c today + readJSON(t, "testdata/today-composed.json", &c) + s := goldenMesh(t, c) + published(t, s, c) + h := &Holder{Store: s, Asks: &router{}, Logf: t.Logf, Now: time.Now} + h.Compose = FromCompositions(s) + if _, err := h.ComposeNode(context.Background(), c.Node); err != nil { + t.Fatal(err) + } + plan, _ := s.Get(PlansState, "n1.restic") + key := mustKey(delivery.ComposedKey("n1", "restic", fpsOf["restic"])) + s.write(t, ComposedRead, key, `{"v":1,"node":"n1","module":"restic","fp":"`+fpsOf["restic"]+`","resources":[],`+ + `"refused":"restic requires postgres-database and no module offers it"}`) + out, err := h.ComposeNode(context.Background(), c.Node) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.Held["restic"], "no module offers it") { + t.Errorf("the refused module is held as %v", out.Held) + } + if e, _ := s.Get(PlansState, "n1.restic"); e == nil || string(e.Value) != string(plan.Value) { + t.Errorf("the held module's plan was not kept: %s", valueOf(e)) + } + if e, _ := s.Get(RefusedState, "n1.restic"); e == nil || !strings.Contains(string(e.Value), "no module offers it") { + t.Errorf("refused says %s", valueOf(e)) + } + if p, _ := s.Get(ProvisionsState, "n1.restic.postgres-database"); p == nil || strings.Contains(string(p.Value), "retiring") { + t.Errorf("the held module's key: %s", valueOf(p)) + } + // Not published yet: the want moved to a build the engine has not composed. + other := "src2:" + strings.Repeat("9", 64) + s.write(t, WantsRead, "want.n1.restic", `{"v":1,"fp":"`+other+`","self":true}`) + out, err = h.ComposeNode(context.Background(), c.Node) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.Held["restic"], "not published") { + t.Errorf("the unpublished composition holds restic as %v", out.Held) + } + if e, _ := s.Get(PlansState, "n1.restic"); e == nil || string(e.Value) != string(plan.Value) { + t.Errorf("the plan was not kept while unpublished: %s", valueOf(e)) + } +} + +func readJSON(t *testing.T, path string, v any) { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, v); err != nil { + t.Fatal(err) + } +} + +func sameBytes(t *testing.T, a, b []byte) bool { + t.Helper() + var x, y any + if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { + return false + } + ra, _ := json.Marshal(x) + rb, _ := json.Marshal(y) + return string(ra) == string(rb) +} + +func valueOf(e *Entry) string { + if e == nil { + return "(none)" + } + return string(e.Value) +} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go index fb00bf2b..196855de 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go @@ -1,14 +1,16 @@ package main -// The seat's holder (novox/hq ADR 0570 clauses 1 to 5, 7; issue 1954 H1-H3): mesh-provisioner composes every placed -// module's provisioning plan and every provision from bus state, on change events and never on a timer, and keeps them -// in its states `plans` (`.`) and `provisions` (`..[.]`), which it alone -// writes. +// The seat's holder (novox/hq ADR 0570 clauses 1 to 5, 7, as changed by ADR 0593 §8; issue 1954 H1-H3): mesh-provisioner +// makes every placed module's provisioning plan and every provision from bus state, on change events and never on a +// timer, and keeps them in its states `plans` (`.`) and `provisions` +// (`..[.]`), which it alone writes. It composes nothing: each plan is the one the +// node-engine's published composition carries (`composed-`, ADR 0593 §6), the node-engine alone composing. // -// - **Composed per node.** A change of a node's placement or of one of its wants composes that node; a change of the -// indexer's module composes the nodes the module is placed on. Each node is composed twice: at the builds its wants -// name (its plans, what its client applies) and at the newest build of each placed module, so a build that adds a -// requirement has its key before its want (clause 2). A provision is needed while either needs it (clause 5). +// - **Made per node.** A change of a node's placement, of one of its wants or of one of its published compositions +// makes that node's plans again; a change of the indexer's module does it for the nodes the module is placed on. +// Each node's plans are read twice: at the builds its wants name (its plans, what its client applies) and at the +// newest build of each placed module, so a build that adds a requirement has its key before its want (clause 2). +// A provision is needed while either needs it (clause 5). // - **A key's state comes from its provider's outcome alone** (clause 3, 7): the provider's own entry for that // consumer, `_bound` under the same key: present is provided (the vault's generation carried into the // reference, clause 6), refused is failed with why, waits is waits with what. The holder never sets provided by @@ -32,8 +34,6 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" - "git.novox.be/novox/mesh-sdk/go/compose/fromruntime" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/provisions" ) @@ -54,15 +54,15 @@ type Refusal struct { Since time.Time `json:"since"` } -// The reads it composes from. +// The reads it makes the plans from. ComposedRead is every node's published compositions, `composed-` (ADR 0593 +// §6), read as the node-engine's named state: its keys are `..` and `.node`. const ( PlacementRead = delivery.PlacementRead WantsRead = delivery.AssignerModule + ".wants" ModulesRead = delivery.IndexerModule + ".modules" ManifestsRead = delivery.IndexerModule + ".manifests" - BuildsRead = delivery.BuilderModule + ".builds" HaveRead = "node-engine.have" - ResolvedRead = "mesh-config.resolved" + ComposedRead = "mesh-host.composed" ) // BoundReads are the providers' entries this module's manifest reads, `.bound`, whose changes it hears. Which @@ -75,17 +75,29 @@ var BoundReads = []string{"gitea", "keycloak", "postgres", "route-adapter", "rou // boundRead is a provider's entries as this module reads them. func boundRead(module string) string { return module + ".bound" } -// Composer is a node's provisioning plans, composed from the bus over one read: at the builds keptAt names (module → -// fingerprint, the newest build of every other placed module), and at the newest build of each placed module (issue -// 1954: once, not twice). A module whose build or plan refuses is held (frombus.PlansHeld; issue 774, one bad module -// must not drop its node): in held with why, none of its plan in plans; a scope's Err is a refusal that is no single -// module's. +// Composer is a node's provisioning plans: at the builds keptAt names (module → fingerprint, the newest build of every +// other placed module), and at the newest build of each placed module. A module whose composition is refused, or not +// published yet, is held (issue 774, one bad module must not drop its node): in held with why, none of its plan in +// plans; a scope's Err is a failure that is no single module's. type Composer func(ctx context.Context, node string, keptAt map[string]string) Composition // Composition is a node's plans at its wanted builds and at its newest, and where the time went. type Composition struct { - AtWants, Newest frombus.HeldPlans - Took []frombus.Phase + AtWants, Newest HeldPlans + Took []Phase +} + +// HeldPlans is one scope's plans by module, the modules held with why, and a failure that is no single module's. +type HeldPlans struct { + Plans map[string]catalogue.Plan + Held map[string]string + Err error +} + +// Phase is one step of reading a node's plans and how long it took. +type Phase struct { + Name string + Took time.Duration } // Asker is the operator's channel as this module asks on it: publish an ask under its own name, and read the router's @@ -1106,25 +1118,139 @@ func orSaid(words ...string) string { return "" } -// FromWatched is the holder's composer over one watched copy of its reads (novox/hq ADR 0580 clause 2): frombus's plans -// of a node, at the builds keptAt names and at the newest over one read of the copy (placement, haves, modules, -// resolved values), a build by key and a manifest once per digest; no state is read key by key per composition. A plan -// names no value, so no own secret and no credential is read (compose.Planning). -func FromWatched(ws *stdio.WatchedStates) Composer { - return composerOver(fromruntime.WatchedReader(ws)) -} - -// composerOver is the holder's composer over a reader: both scopes over one read (frombus.PlansHeldTwice). -func composerOver(r frombus.Reader) Composer { - return func(ctx context.Context, node string, keptAt map[string]string) Composition { - at, newest, took := frombus.PlansHeldTwice(ctx, r, frombus.Mesh{}, frombus.Scope{Node: node, KeptAt: keptAt}) - return Composition{AtWants: at, Newest: newest, Took: took} +// FromCompositions is the holder's composer over the node-engine's published compositions (ADR 0593 §8): it composes +// nothing, and takes each module's plan from the key of `composed-` at the build it is read at. At the wanted +// builds a module is read at the fingerprint keptAt names, else at the indexer's newest (FP2); at the newest, each placed +// module at FP2. A key whose engine refused the module holds it with the engine's words; a key not published yet holds +// it at its wanted build (its last plan kept, as for a refusal) and leaves it out of the newest, whose build the engine +// writes once it is done (ADR 0593 §6). A plan absent from a key is a module that hands the provisioner nothing. +func FromCompositions(s Store) Composer { + return func(_ context.Context, node string, keptAt map[string]string) Composition { + start := time.Now() + at, newest := HeldPlans{Plans: map[string]catalogue.Plan{}, Held: map[string]string{}}, + HeldPlans{Plans: map[string]catalogue.Plan{}, Held: map[string]string{}} + published, err := publishedOn(s, node) + if err != nil { + at.Err, newest.Err = err, err + return Composition{AtWants: at, Newest: newest, Took: []Phase{{Name: "compositions read", Took: time.Since(start)}}} + } + placed, fp2 := placedModules(s, node) + atWants := map[string]string{} + for _, m := range placed { + atWants[m] = fp2[m] + } + for m, fp := range keptAt { + atWants[m] = fp + } + for _, m := range sortedKeys(atWants) { + fp := atWants[m] + if fp == "" { + continue + } + c, ok := published[composedAt(node, m, fp)] + switch { + case !ok: + at.Held[m] = fmt.Sprintf("the composition of %s at %s is not published by the node-engine of %s yet", m, fp, node) + case c.Refused != "": + at.Held[m] = c.Refused + case c.Plan != nil: + at.Plans[m] = *c.Plan + } + } + for _, m := range placed { + if fp2[m] == "" { + continue + } + c, ok := published[composedAt(node, m, fp2[m])] + switch { + case !ok: + case c.Refused != "": + newest.Held[m] = c.Refused + case c.Plan != nil: + newest.Plans[m] = *c.Plan + } + } + return Composition{AtWants: at, Newest: newest, Took: []Phase{{Name: "compositions read", Took: time.Since(start)}}} } } -// WatchedReads are the states the holder reads whole, each from the watched copy: what the composer reads and the -// wants (ADR 0580 clause 2), and the providers' entries, whose watch is also what rejudges a key. -var WatchedReads = append([]string{PlacementRead, WantsRead, ModulesRead, HaveRead, ResolvedRead}, boundReads()...) +// publishedPlan is what the holder reads of a published composition (delivery.Composed): its plan, or the engine's +// refusal. Read leniently, so a field the node-engine adds later is no failure here. +type publishedPlan struct { + Plan *catalogue.Plan `json:"plan"` + Refused string `json:"refused"` +} + +// composedAt is a module's key in its node's compositions, or "" for a fingerprint no key can name. +func composedAt(node, module, fp string) string { + k, err := delivery.ComposedKey(node, module, fp) + if err != nil { + return "" + } + return k +} + +// publishedOn is every module composition the node's engine publishes, by key. +func publishedOn(s Store, node string) (map[string]publishedPlan, error) { + keys, err := s.Keys(ComposedRead) + if err != nil { + return nil, fmt.Errorf("the compositions the node-engines publish cannot be read: %w", err) + } + out := map[string]publishedPlan{} + for _, k := range keys { + n, _, _, isNode, err := delivery.ParseComposedKey(k) + if err != nil || isNode || n != node { + continue + } + e, err := s.Get(ComposedRead, k) + if err != nil || e == nil { + continue + } + var c publishedPlan + if err := json.Unmarshal(e.Value, &c); err != nil { + c.Refused = fmt.Sprintf("its published composition %s does not read: %v", k, err) + } + out[k] = c + } + return out, nil +} + +// placedModules is every module placed on node, sorted, and the indexer's newest fingerprint (FP2) of each. +func placedModules(s Store, node string) ([]string, map[string]string) { + raw := map[string][]byte{} + if keys, err := s.Keys(PlacementRead); err == nil { + for _, k := range keys { + if n, _, _, err := delivery.ParsePlacementKey(k); err != nil || n != node { + continue + } + if e, err := s.Get(PlacementRead, k); err == nil && e != nil { + raw[k] = e.Value + } + } + } + placements, _ := delivery.PlacementsOf(raw) + var placed []string + fp2 := map[string]string{} + for _, m := range placements[node].Modules { + placed = append(placed, m.Module) + mk, err := delivery.ModulesKey(m.Module) + if err != nil { + continue + } + if e, err := s.Get(ModulesRead, mk); err == nil && e != nil { + if mod, err := delivery.DecodeModule(e.Value); err == nil { + fp2[m.Module] = mod.FP2 + } + } + } + sort.Strings(placed) + return placed, fp2 +} + +// WatchedReads are the states the holder reads whole, each from the watched copy (ADR 0580 clause 2): the placement, +// the wants, the indexer's modules, the haves and the published compositions, and the providers' entries, whose watch +// is also what rejudges a key. +var WatchedReads = append([]string{PlacementRead, WantsRead, ModulesRead, HaveRead, ComposedRead}, boundReads()...) // OwnStates are the states the holder alone writes (ADR 0570 clause 1), read from the watched copy too (issue 1954: some // 250 to 400 reads by key per composition): each of its own writes is taken into the copy at once (WatchedStates.Wrote), @@ -1206,8 +1332,9 @@ func (w watchingStore) Delete(state, key string) error { return w.ws.Removed(state, key) } -// NodesOfNodeKey is the node a key that starts with it names: a resolved value's `..`, a have's -// `.` or `.node`. A change of either recomposes that node (ADR 0580 clause 2). +// NodesOfNodeKey is the node a key that starts with it names: a have's `.` or `.node`, a published +// composition's `..` or `.node`. A change of either makes that node's plans +// again (ADR 0580 clause 2, ADR 0593 §8). func NodesOfNodeKey(key string) []string { if n, _, ok := strings.Cut(key, "."); ok && n != "" { return []string{n} 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 e5a56156..6ab8f93e 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go @@ -2,12 +2,13 @@ package main -// The holder against a real bus (novox/hq ADR 0570 clauses 1-3, 5; issue 1954 H5): its states and every input it reads -// are a real nats-server's key-value buckets, its plans composed by mesh-sdk's composition from the watched copy of those -// buckets (ADR 0580 clause 2) filled with the mesh frombustest gives (restic on n1, requiring postgres-database from -// postgres there); no input is read key by key per composition. A placement and a -// want compose the key; the providers' outcomes (the vault's entry for the credential, postgres's own have) turn it -// provided; restic leaving the node makes it retiring and raises the ask. +// The holder against a real bus (novox/hq ADR 0570 clauses 1-3, 5, as changed by ADR 0593 §8; issue 1954 H5): its +// states and every input it reads are a real nats-server's key-value buckets, read through the watched copy of those +// buckets (ADR 0580 clause 2); the plans come from n1's published compositions in `composed-n1` (ADR 0593 §6), as +// captured from the composition of mesh-sdk's frombustest mesh (restic on n1, requiring postgres-database from postgres +// there), and nothing is composed. A placement, a want and a published composition make the key; the providers' +// outcomes (the vault's entry for the credential, postgres's own have) turn it provided; restic leaving the node makes +// it retiring and raises the ask. import ( "context" @@ -22,9 +23,6 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" - "git.novox.be/novox/mesh-sdk/go/compose/frombus/frombustest" - "git.novox.be/novox/mesh-sdk/go/compose/fromruntime" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/provisions" "git.novox.be/novox/mesh-sdk/go/testbus" @@ -37,6 +35,9 @@ type kvStore struct { } func bucketOf(state string) string { + if state == ComposedRead { // the node-engine's named state: here n1's bucket, the one node that composes + return delivery.ComposedBucketPrefix + "n1" + } if owner, name, ok := strings.Cut(state, "."); ok { return owner + "_" + name } @@ -109,7 +110,7 @@ func (a *asked) Publish(_ string, body any, id string) error { func (a *asked) Record(string) ([]byte, bool, error) { return nil, false, nil } // 1954/H5. -func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) { +func TestTheHolderPlansProvidesAndAsksOnTheBus(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) nc, err := nats.Connect(testbus.URL(t)) @@ -122,85 +123,26 @@ func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) { t.Fatal(err) } s := kvStore{ctx: ctx, js: js} - mesh, err := frombustest.Provisioned() - if err != nil { - t.Fatal(err) - } - node := frombustest.Node - // The bus as the holder reads it: the indexer's modules and manifests, a placement naming where the vault runs, the - // want of restic on n1. - for module, m := range mesh.Module { - m.V = 1 - raw, _ := json.Marshal(m) - if err := s.Put(ModulesRead, module, json.RawMessage(raw)); err != nil { - t.Fatal(err) - } - } - for digest, raw := range mesh.Manifests { - var named struct { - Module string `json:"module"` - } - _ = json.Unmarshal(raw, &named) - value, _ := delivery.EncodeManifest(delivery.Manifest{Module: named.Module, JSON: string(raw)}) - key, _ := delivery.ManifestsKey(digest) - if err := s.Put(ManifestsRead, key, json.RawMessage(value)); err != nil { - t.Fatal(err) - } - } - // The vault keeps its outcomes as entries: its manifest declares `bound`. - vaultManifest := `{"module":"mesh-vault","version":"1","state":["bound"]}` + var c today + readJSON(t, "testdata/today-composed.json", &c) + node := c.Node + // The bus as the holder reads it: the indexer's modules and manifests, n1's placement and the vault's on n2, the + // want of restic on n1, and what n1's engine published. + mem := goldenMesh(t, c) + published(t, mem, c) + vaultManifest := `{"module":"mesh-vault","version":"1","state":["bound"]}` // the vault keeps its outcomes as entries vaultDigest := delivery.ManifestDigest([]byte(vaultManifest)) vaultValue, _ := delivery.EncodeManifest(delivery.Manifest{Module: provisions.VaultModule, JSON: vaultManifest}) - vaultKey, _ := delivery.ManifestsKey(vaultDigest) - if err := s.Put(ManifestsRead, vaultKey, json.RawMessage(vaultValue)); err != nil { - t.Fatal(err) - } - if err := s.Put(ModulesRead, provisions.VaultModule, map[string]any{"v": 1, "none2": "the test's", "manifest": vaultDigest, - "repository": "novox/mesh-catalog", "commit": "c1"}); err != nil { - t.Fatal(err) - } - vault, _ := json.Marshal(map[string]any{"v": 1, "node": "n2", "decided": map[string]any{}, - "modules": []map[string]any{{"module": provisions.VaultModule}}}) - if err := s.Put(PlacementRead, "placement.n2", json.RawMessage(vault)); err != nil { - t.Fatal(err) - } - fp := frombustest.FPs()["restic"] - if err := s.Put(WantsRead, "want."+node+".restic", map[string]any{"v": 1, "fp": fp, "self": true}); err != nil { - t.Fatal(err) - } - // The rest of the mesh on the bus as its writers keep it: n1's placement, its record in its have, the builds. - putPlacement := func() { - raw, err := delivery.EncodePlacement(mesh.Placement[node]) - if err != nil { - t.Fatal(err) - } - if err := s.Put(PlacementRead, "placement."+node, json.RawMessage(raw)); err != nil { - t.Fatal(err) + mem.write(t, ManifestsRead, mustKey(delivery.ManifestsKey(vaultDigest)), string(vaultValue)) + mem.write(t, ModulesRead, provisions.VaultModule, `{"v":1,"none2":"the test's","manifest":"`+vaultDigest+`"}`) + for state, keys := range mem.states { + for key, raw := range keys { + if err := s.Put(state, key, json.RawMessage(raw)); err != nil { + t.Fatal(err) + } } } - putPlacement() - for _, n := range []string{node, "n2"} { // n2, where the vault runs, has its record too - rec := mesh.Record[node] - rec.Node = n - recRaw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) - if err != nil { - t.Fatal(err) - } - if err := s.Put(HaveRead, mustKey(delivery.HaveNodeKey(n)), json.RawMessage(recRaw)); err != nil { - t.Fatal(err) - } - } - for at, b := range mesh.Builds { - module, fp, _ := strings.Cut(at, " ") - raw, err := delivery.EncodeBuilderBuild(*b) - if err != nil { - t.Fatal(err) - } - if err := s.Put(BuildsRead, mustKey(delivery.BuilderBuildKey(module, fp)), json.RawMessage(raw)); err != nil { - t.Fatal(err) - } - } - // The watched copy over the real buckets: each state by one watch, a build and a manifest by key. + // The watched copy over the real buckets: each state by one watch, a manifest by key. ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error { kv, err := s.kv(state) if err != nil { @@ -250,18 +192,9 @@ func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) { } } } - fetch := func(_ context.Context, url string) ([]byte, error) { - for digest, body := range mesh.Archives { - if strings.HasSuffix(url, digest) { - return body, nil - } - } - return nil, errors.New("no archive at " + url) - } - reader := frombus.StatesReader(fromruntime.Watched{WatchedStates: ws}, fetch) a := &asked{} - h := &Holder{Store: Watching(s, ws), Asks: a, Now: time.Now, Logf: t.Logf, - Compose: composerOver(reader)} + h := &Holder{Store: Watching(s, ws), Asks: a, Now: time.Now, Logf: t.Logf} + h.Compose = FromCompositions(h.Store) state := func(key string) provisions.Provision { e, _ := s.Get(ProvisionsState, key) if e == nil { @@ -286,7 +219,7 @@ func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) { t.Fatal(err) } } - if err := s.Put(HaveRead, node+".postgres", map[string]any{"v": 1, "fp": frombustest.FPs()["postgres"], "phase": "running", + if err := s.Put(HaveRead, node+".postgres", map[string]any{"v": 1, "fp": fpsOf["postgres"], "phase": "running", "health": "ok"}); err != nil { t.Fatal(err) } @@ -299,17 +232,11 @@ func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) { if p := state(key); p.State != provisions.Provided { t.Fatalf("the outcomes did not turn it provided: %+v", p) } - // restic leaves the node and its want goes: retiring, one ask. - p := mesh.Placement[node] - var kept []delivery.PlacedModule - for _, m := range p.Modules { - if m.Module != "restic" { - kept = append(kept, m) - } + // restic leaves the node and its want goes: retiring, one ask. Its compositions stay until its engine deletes them. + if err := s.Put(PlacementRead, "placement."+node, json.RawMessage(`{"v":1,"node":"`+node+`","decided":{},`+ + `"modules":[{"module":"store"},{"module":"postgres"},{"module":"mesh-provisioner-client"}]}`)); err != nil { + t.Fatal(err) } - p.Modules = kept - mesh.Placement[node] = p - putPlacement() if err := s.Delete(WantsRead, "want."+node+".restic"); 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 d737cc94..0ba670fc 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go @@ -15,7 +15,6 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/provisions" ) @@ -71,8 +70,8 @@ func newMesh() *mesh { Now: func() time.Time { now = now.Add(time.Second); return now }, Compose: func(_ context.Context, node string, keptAt map[string]string) Composition { m.composed = append(m.composed, node) - return Composition{AtWants: frombus.HeldPlans{Plans: m.atWant[node], Held: m.heldAt[node]}, - Newest: frombus.HeldPlans{Plans: m.atNewest[node]}} + return Composition{AtWants: HeldPlans{Plans: m.atWant[node], Held: m.heldAt[node]}, + Newest: HeldPlans{Plans: m.atNewest[node]}} }} // What each provider's manifest declares: postgres, keycloak and the vault keep entries, the others report by their // have. @@ -386,10 +385,10 @@ func TestAModuleHeldWithoutAWantIsComposedAtTheBuildItRuns(t *testing.T) { var keptAt map[string]string m.h.Compose = func(_ context.Context, node string, at map[string]string) Composition { keptAt = at - return Composition{AtWants: frombus.HeldPlans{Plans: map[string]catalogue.Plan{"nats": {}}}} + return Composition{AtWants: HeldPlans{Plans: map[string]catalogue.Plan{"nats": {}}}} } // ADR 0580 clause 2: the reads come from one watched copy, each state watched once and none read key by key from the - // bus, and a change the copy takes wakes the node it touches (a resolved value its node). + // bus, and a change the copy takes wakes the node it touches (a published composition its node). bus := &readsOf{Store: m.s, read: map[string]int{}} handles, watches := map[string]func(stdio.StateChange) error{}, map[string]int{} ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error { @@ -404,7 +403,7 @@ func TestAModuleHeldWithoutAWantIsComposedAtTheBuildItRuns(t *testing.T) { }, func(string, string) ([]byte, bool, error) { return nil, false, nil }) m.h.Store = Watching(bus, ws) wk := &work{pending: map[string]bool{}, wake: make(chan struct{}, 1)} - for _, r := range []string{ResolvedRead, HaveRead} { + for _, r := range []string{ComposedRead, HaveRead} { ws.Follow(r, func(c stdio.StateChange) error { wk.add(NodesOfNodeKey(c.Key)...); return nil }) } for i := range 2 { @@ -429,12 +428,14 @@ func TestAModuleHeldWithoutAWantIsComposedAtTheBuildItRuns(t *testing.T) { t.Errorf("%s was read %d times from the bus and watched %d times", r, bus.read[r], watches[r]) } } - // The current haves queued their nodes when watched; a later change of a resolved value or a have queues its node. + // The current haves queued their nodes when watched; a later change of a published composition or a have + // queues its node. wk.pending = map[string]bool{} - _ = handles[ResolvedRead](stdio.StateChange{Key: "g14.dunst.font-size", Op: stdio.OpPut, Value: []byte("13")}) + _ = handles[ComposedRead](stdio.StateChange{Key: "g14.dunst." + strings.Repeat("ee", 32), Op: stdio.OpPut, + Value: []byte("{}")}) _ = handles[HaveRead](stdio.StateChange{Key: "ace.restic", Op: stdio.OpDelete}) if !wk.pending["g14"] || !wk.pending["ace"] || len(wk.pending) != 2 { - t.Errorf("a resolved value's and a have's change queued %v, want g14 and ace", wk.pending) + t.Errorf("a published composition's and a have's change queued %v, want g14 and ace", wk.pending) } } diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holdertools.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holdertools.go index 8690cd73..12b4c906 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holdertools.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holdertools.go @@ -1,7 +1,7 @@ package main // The holder's tools (novox/hq ADR 0570 Consequences, ADR 0564 §12's rule): a read of every key of `provisions` and -// `plans`, one key whole, each state's fill, composing one node now, and reading the operator's answers now. +// `plans`, one key whole, each state's fill, making one node's plans now, and reading the operator's answers now. import ( "context" @@ -36,9 +36,10 @@ func holderTools(h *Holder) []stdio.Tool { return h.read(ProvisionsState, arg(a, "key"), arg(a, "node"), arg(a, "state")) }}, {Name: PlansTool, - Description: "Every module's provisioning plan the seat composed, ., narrowed by node, with how many " + - "keys and bytes the state holds, and each placed module held with no plan composed and why (refused: its " + - "manifest or its plan refuses, the node's other modules composed); or one plan whole.", + Description: "Every module's provisioning plan the seat keeps, ., as the node-engine's published " + + "composition carries it (composed-), narrowed by node, with how many keys and bytes the state holds, " + + "and each placed module held with its last plan kept and why (refused: the node-engine refused its " + + "composition, or has not published it at the wanted build yet); or one plan whole.", Input: map[string]any{"type": "object", "properties": map[string]any{"key": str("one plan, ."), "node": str("a node")}}, Run: func(a map[string]any) (any, error) { @@ -54,8 +55,9 @@ func holderTools(h *Holder) []stdio.Tool { return out, nil }}, {Name: ComposeTool, - Description: "Compose one node's plans and provisions now from the bus, as a change event would, and say what " + - "was written, removed, changed and asked.", + Description: "Make one node's plans and provisions now from the node-engine's published compositions and the " + + "providers' outcomes, as a change event would (it composes nothing), and say what was written, removed, " + + "changed and asked, and where the time went.", Input: map[string]any{"type": "object", "properties": map[string]any{"node": str("the node")}, "required": []string{"node"}}, Run: func(a map[string]any) (any, error) { diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/main.go b/modules/mesh-provisioner/cmd/mesh-provisioner/main.go index 33d50c83..ad007f0d 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/main.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/main.go @@ -1,7 +1,7 @@ // mesh-provisioner: the provisioner's server, holder of the mesh-provisioner seat once for the mesh (novox/hq ADR // 0349 clause 3, ADR 0570). A Go bundle served by a runtime of its own, on its own bus account (`runs-as`, ADR 0259 -// §8), keeping mesh-provisioner_provisions and _plans (every provision and every plan, composed from the bus on change -// events, its alone), _registry (which domain answers which kind of placeholder, its alone), and _applied and +// §8), keeping mesh-provisioner_provisions and _plans (every provision and every plan, made from the node-engines' +// published compositions on change events, its alone; novox/hq ADR 0593 §8), _registry (which domain answers which kind of placeholder, its alone), and _applied and // _collect, which each machine's provisioner client writes for its own machine; _needs is retired and emptied at its // start. stdout is the MCP channel; what this module says, it says on stderr. package main @@ -125,14 +125,15 @@ func slowest(took []Took) time.Duration { func main() { s := runtimeStore{} ws := stdio.NewWatchedStates() // one copy of what it reads, each state by its watch (ADR 0580 clause 2) - h := &Holder{Store: Watching(s, ws), Compose: FromWatched(ws), Asks: runtimeAsks{}, Now: time.Now, Logf: logf} + h := &Holder{Store: Watching(s, ws), Asks: runtimeAsks{}, Now: time.Now, Logf: logf} + h.Compose = FromCompositions(h.Store) // the node-engines' published compositions, from the copy (ADR 0593 §8) w := &work{pending: map[string]bool{}, wake: make(chan struct{}, 1)} go w.run(h) go starting(h) // Each watch's handler runs after the copy took its change, so a composition it wakes reads the copy that change made. go ws.Follow(PlacementRead, func(c stdio.StateChange) error { w.add(NodesOfPlacement(c.Key)...); return nil }) go ws.Follow(WantsRead, func(c stdio.StateChange) error { w.add(NodesOfWant(c.Key)...); return nil }) - go ws.Follow(ResolvedRead, func(c stdio.StateChange) error { w.add(NodesOfNodeKey(c.Key)...); return nil }) + go ws.Follow(ComposedRead, func(c stdio.StateChange) error { w.add(NodesOfNodeKey(c.Key)...); return nil }) go ws.Follow(HaveRead, func(c stdio.StateChange) error { w.add(NodesOfNodeKey(c.Key)...); return nil }) go ws.Follow(ModulesRead, func(c stdio.StateChange) error { if module, err := delivery.ParseModulesKey(c.Key); err == nil { diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go index f717ea06..7ef2233a 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go @@ -9,7 +9,7 @@ import ( "sync" "testing" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // memStore is the bus as mesh-provisioner reaches it, in memory: the states it keeps. @@ -326,9 +326,15 @@ func TestTheManifestDeclaresWhatTheCodeKeeps(t *testing.T) { t.Errorf("the manifest lists %s, which is not served", tool) } } - for _, r := range append([]string{WantsRead}, frombus.StatesReads...) { // what fromruntime.Reader reads + for _, r := range append(slices.Clone(WatchedReads), ManifestsRead) { if !slices.Contains(m.Reads, r) { - t.Errorf("the manifest does not read %s, which the composition 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"} { + if slices.Contains(m.Reads, r) { + t.Errorf("the manifest reads %s, which only a composer reads", r) } } for _, p := range BoundReads { diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/ahead-today-composed.json b/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/ahead-today-composed.json new file mode 100644 index 00000000..a2275e76 --- /dev/null +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/ahead-today-composed.json @@ -0,0 +1,100 @@ +{ + "node": "n1", + "kept-at": { + "restic": "src2:2222222222222222222222222222222222222222222222222222222222222222" + }, + "at-wants": { + "Plans": { + "restic": { + "templates": { + "source": "http://127.0.0.1:5000/v2/restic/files/blobs/e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e", + "digest": "e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e" + }, + "files": [ + { + "id": "declared", + "path": "/var/lib/restic/backups.conf", + "template": "backups.conf", + "owner-at-runtime": "owned" + }, + { + "id": "data-declared", + "path": "/var/lib/restic/data.conf", + "template": "data.conf", + "owner-at-runtime": "owned" + }, + { + "id": "retention", + "path": "/var/lib/restic/retention.conf", + "template": "retention.conf", + "owner-at-runtime": "owned" + } + ], + "placement": { + "backup:data": "# restic\nitem repository rebuildable /var/lib/restic/repository - none walk\nitem state cache /var/lib/restic - none walk\n", + "backup:lines": "" + }, + "secrets": [ + { + "name": "repository", + "path": "/var/lib/restic/repository.secret" + } + ] + } + }, + "Held": {}, + "Err": null + }, + "newest": { + "Plans": { + "restic": { + "templates": { + "source": "http://127.0.0.1:5000/v2/restic/files/blobs/e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e", + "digest": "e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e" + }, + "files": [ + { + "id": "declared", + "path": "/var/lib/restic/backups.conf", + "template": "backups.conf", + "owner-at-runtime": "owned" + }, + { + "id": "data-declared", + "path": "/var/lib/restic/data.conf", + "template": "data.conf", + "owner-at-runtime": "owned" + }, + { + "id": "retention", + "path": "/var/lib/restic/retention.conf", + "template": "retention.conf", + "owner-at-runtime": "owned" + } + ], + "placement": { + "backup:data": "# restic\nitem repository rebuildable /var/lib/restic/repository - none walk\nitem state cache /var/lib/restic - none walk\n", + "backup:lines": "" + }, + "secrets": [ + { + "name": "repository", + "path": "/var/lib/restic/repository.secret" + } + ], + "provisions": [ + { + "provision": "postgres-database", + "provider": "n1", + "provider-module": "postgres", + "at": "127.0.0.1", + "from": "n1", + "as": "mesh_n1_restic" + } + ] + } + }, + "Held": {}, + "Err": null + } +} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/ahead-today-states.golden.json b/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/ahead-today-states.golden.json new file mode 100644 index 00000000..dd17dcc2 --- /dev/null +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/ahead-today-states.golden.json @@ -0,0 +1,71 @@ +{ + "plans": { + "n1.restic": { + "v": 1, + "node": "n1", + "module": "restic", + "plan": { + "templates": { + "source": "http://127.0.0.1:5000/v2/restic/files/blobs/e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e", + "digest": "e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e" + }, + "files": [ + { + "id": "declared", + "path": "/var/lib/restic/backups.conf", + "template": "backups.conf", + "owner-at-runtime": "owned" + }, + { + "id": "data-declared", + "path": "/var/lib/restic/data.conf", + "template": "data.conf", + "owner-at-runtime": "owned" + }, + { + "id": "retention", + "path": "/var/lib/restic/retention.conf", + "template": "retention.conf", + "owner-at-runtime": "owned" + } + ], + "placement": { + "backup:data": "# restic\nitem repository rebuildable /var/lib/restic/repository - none walk\nitem state cache /var/lib/restic - none walk\n", + "backup:lines": "" + }, + "secrets": [ + { + "name": "repository", + "path": "/var/lib/restic/repository.secret" + } + ] + } + } + }, + "provisions": { + "n1.restic.postgres-database": { + "v": 1, + "provision": "postgres-database", + "consumer": { + "module": "restic", + "node": "n1" + }, + "provider": { + "module": "postgres", + "node": "n1" + }, + "state": "asked", + "why": "no plan names it yet: the newest build of restic requires it and its wanted build does not, so its provider binds it from this key's plan entry; waits for postgres on n1 to run: it says nothing yet", + "plan": { + "provision": "postgres-database", + "provider": "n1", + "provider-module": "postgres", + "at": "127.0.0.1", + "from": "n1", + "as": "mesh_n1_restic" + }, + "at": "2026-11-02T18:00:00Z" + } + }, + "refused": {} +} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/today-composed.json b/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/today-composed.json new file mode 100644 index 00000000..537b17a8 --- /dev/null +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/today-composed.json @@ -0,0 +1,110 @@ +{ + "node": "n1", + "kept-at": { + "restic": "src2:3333333333333333333333333333333333333333333333333333333333333333" + }, + "at-wants": { + "Plans": { + "restic": { + "templates": { + "source": "http://127.0.0.1:5000/v2/restic/files/blobs/e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e", + "digest": "e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e" + }, + "files": [ + { + "id": "declared", + "path": "/var/lib/restic/backups.conf", + "template": "backups.conf", + "owner-at-runtime": "owned" + }, + { + "id": "data-declared", + "path": "/var/lib/restic/data.conf", + "template": "data.conf", + "owner-at-runtime": "owned" + }, + { + "id": "retention", + "path": "/var/lib/restic/retention.conf", + "template": "retention.conf", + "owner-at-runtime": "owned" + } + ], + "placement": { + "backup:data": "# restic\nitem repository rebuildable /var/lib/restic/repository - none walk\nitem state cache /var/lib/restic - none walk\n", + "backup:lines": "" + }, + "secrets": [ + { + "name": "repository", + "path": "/var/lib/restic/repository.secret" + } + ], + "provisions": [ + { + "provision": "postgres-database", + "provider": "n1", + "provider-module": "postgres", + "at": "127.0.0.1", + "from": "n1", + "as": "mesh_n1_restic" + } + ] + } + }, + "Held": {}, + "Err": null + }, + "newest": { + "Plans": { + "restic": { + "templates": { + "source": "http://127.0.0.1:5000/v2/restic/files/blobs/e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e", + "digest": "e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e" + }, + "files": [ + { + "id": "declared", + "path": "/var/lib/restic/backups.conf", + "template": "backups.conf", + "owner-at-runtime": "owned" + }, + { + "id": "data-declared", + "path": "/var/lib/restic/data.conf", + "template": "data.conf", + "owner-at-runtime": "owned" + }, + { + "id": "retention", + "path": "/var/lib/restic/retention.conf", + "template": "retention.conf", + "owner-at-runtime": "owned" + } + ], + "placement": { + "backup:data": "# restic\nitem repository rebuildable /var/lib/restic/repository - none walk\nitem state cache /var/lib/restic - none walk\n", + "backup:lines": "" + }, + "secrets": [ + { + "name": "repository", + "path": "/var/lib/restic/repository.secret" + } + ], + "provisions": [ + { + "provision": "postgres-database", + "provider": "n1", + "provider-module": "postgres", + "at": "127.0.0.1", + "from": "n1", + "as": "mesh_n1_restic" + } + ] + } + }, + "Held": {}, + "Err": null + } +} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/today-states.golden.json b/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/today-states.golden.json new file mode 100644 index 00000000..0bd4fd51 --- /dev/null +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/testdata/today-states.golden.json @@ -0,0 +1,73 @@ +{ + "plans": { + "n1.restic": { + "v": 1, + "node": "n1", + "module": "restic", + "plan": { + "templates": { + "source": "http://127.0.0.1:5000/v2/restic/files/blobs/e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e", + "digest": "e30dc95f314f1a9f5365deef50f14f7a36ccaaaa34038621bbfa3a13509fca2e" + }, + "files": [ + { + "id": "declared", + "path": "/var/lib/restic/backups.conf", + "template": "backups.conf", + "owner-at-runtime": "owned" + }, + { + "id": "data-declared", + "path": "/var/lib/restic/data.conf", + "template": "data.conf", + "owner-at-runtime": "owned" + }, + { + "id": "retention", + "path": "/var/lib/restic/retention.conf", + "template": "retention.conf", + "owner-at-runtime": "owned" + } + ], + "placement": { + "backup:data": "# restic\nitem repository rebuildable /var/lib/restic/repository - none walk\nitem state cache /var/lib/restic - none walk\n", + "backup:lines": "" + }, + "secrets": [ + { + "name": "repository", + "path": "/var/lib/restic/repository.secret" + } + ], + "provisions": [ + { + "provision": "postgres-database", + "provider": "n1", + "provider-module": "postgres", + "at": "127.0.0.1", + "from": "n1", + "as": "mesh_n1_restic" + } + ] + } + } + }, + "provisions": { + "n1.restic.postgres-database": { + "v": 1, + "provision": "postgres-database", + "consumer": { + "module": "restic", + "node": "n1" + }, + "provider": { + "module": "postgres", + "node": "n1" + }, + "state": "asked", + "why": "waits for postgres on n1 to run: it says nothing yet", + "at": "2026-11-02T18:00:00Z" + } + }, + "refused": {} +} diff --git a/modules/mesh-provisioner/module.json b/modules/mesh-provisioner/module.json index b7e6d8a5..05a747a8 100644 --- a/modules/mesh-provisioner/module.json +++ b/modules/mesh-provisioner/module.json @@ -19,9 +19,8 @@ "mesh-delivery-assigner.wants", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.manifests", - "mesh-delivery-builder.builds", "node-engine.have", - "mesh-config.resolved", + "mesh-host.composed", "gitea.bound", "keycloak.bound", "postgres.bound",