diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 00b5aa2..2ee61ae 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -509,6 +509,12 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // composes its own (issue 774). Applied: appliedStoreFor(opts.state), Inactive: inactiveIn(&liveness.Exec{Run: liveness.Runner(opts.run()), System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))), + Held: func() map[string]string { + if c := composerNow.Load(); c != nil { + return c.LastHeld() + } + return nil + }, Revisions: func(ctx context.Context, key string) ([]agent.Update, error) { got, err := session.KVHistory(ctx, composedBucket, key) if err != nil { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index b9ff6ff..6fc5f71 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -136,6 +136,9 @@ type Env struct { // Revisions are a composition key's revisions the bucket keeps, oldest first; nil reads none. The composition a want // names is taken from them when a newer one replaced it under the same key (ADR 0539 clause 1). Revisions func(ctx context.Context, key string) ([]Update, error) + // Held is what the node's composer held in its last pass, each module with why, as the verb composition shows it + // for the node's own (issue 774); nil says nothing. + Held func() map[string]string // Settled is told a module's apply or removal that succeeded, once what runs holds it (Everything), outside the // agent's lock: the declaration the node applies now names what that composition names, its filled files among // them. The node-engine's filling tells the provisioner's client then to fill again what it refused (mesh-host diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 9887661..4efc91e 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -12,6 +12,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -1312,6 +1313,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( defer historyMu.Unlock() history[key] = append(history[key], Update{Key: key, Value: raw, Revision: uint64(len(history[key]) + 1)}) } + b.env.Held = func() map[string]string { return map[string]string{"mesh-build-go": "its binding is unset"} } stop := b.run() defer stop() unsigned := func(module, fp string, rs ...json.RawMessage) (string, []byte, string) { @@ -1493,6 +1495,16 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil { t.Error("a digest no revision has was answered") } + // Issue 774 (ace, 2026-11-04 01:29): the node's own revision shows its own fields, the provisioner client's account + // and the adoption among them, and what the composer's last pass held. + ownKey, ownRaw, ownDig := encodedAs(t, delivery.Composed{Node: node, Own: delivery.NodeOwn{Provisioner: "mesh-provisioner-client", + Adoption: &declaration.Adoption{Taken: []string{"web"}}}}, func() string { k, _ := delivery.ComposedNodeKey(node); return k }()) + keep(ownKey, ownRaw) + if view, err := b.ag.Composition(context.Background(), NodeModule, ownDig, false); err != nil || view.Own == nil || + view.Own.Provisioner != "mesh-provisioner-client" || view.Own.Adoption == nil || view.Own.Adoption.Taken[0] != "web" || + view.Own.Held["mesh-build-go"] == "" { + t.Errorf("the node's own composition reads %+v %+v (%v)", view, view.Own, err) + } // novox/hq ADR 0593 §6: the engine publishes each composition with its plan in its composition state; the verb reads // that revision by the digest its have and want name, its resources' (the plan left out of the digest). planned, _ := delivery.DecodeComposed(draw) diff --git a/internal/agent/composition.go b/internal/agent/composition.go index 938c2aa..4035b8b 100644 --- a/internal/agent/composition.go +++ b/internal/agent/composition.go @@ -6,6 +6,7 @@ import ( "fmt" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/judge" ) @@ -32,6 +33,26 @@ type CompositionView struct { Judged JudgedView `json:"judged"` // Diff is every resource that differs from what runs, when asked. Diff []judge.ResourceChange `json:"diff,omitempty"` + // Own is, of the node's own composition (module node), its own fields beside what runs names (issue 774: ace's + // own composition dropped the provisioner client's account, and nothing read could show it). + Own *OwnView `json:"own,omitempty"` +} + +// OwnView is the node's own fields of a composition (delivery.NodeOwn) beside what runs of them, and what the node's +// composer held in its last pass. +type OwnView struct { + // Provisioner is the account the provisioner's client runs as, as the revision read names it; RunsProvisioner as + // what runs names it. Empty: the declaration names none, and every filled write is refused. + Provisioner string `json:"provisioner_account"` + RunsProvisioner string `json:"runs_provisioner_account,omitempty"` + // Adoption is the revision's adoption: the node adopted, and which of its modules are taken; nil for a converged node. + Adoption *declaration.Adoption `json:"adoption,omitempty"` + // Identities, Groups and Libraries count what the revision names of each. + Identities int `json:"identities,omitempty"` + Groups int `json:"groups,omitempty"` + Libraries int `json:"libraries,omitempty"` + // Held is each module the node's composer held in its last pass, with why. + Held map[string]string `json:"held,omitempty"` } // WantView is a want as the verb shows it. @@ -171,6 +192,17 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo } view.Runs = rv } + if module == NodeModule { + o := read.C.Own + view.Own = &OwnView{Provisioner: o.Provisioner, Adoption: o.Adoption, Identities: len(o.Identities), Groups: len(o.Groups), + Libraries: len(o.Libraries)} + if running != nil { + view.Own.RunsProvisioner = running.Own.Provisioner + } + if a.env.Held != nil { + view.Own.Held = a.env.Held() + } + } j := judge.JudgeMove(running, read.C) view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words} if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) { diff --git a/internal/compose/composed.go b/internal/compose/composed.go index 03be678..5d5df71 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -63,6 +63,15 @@ func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (ou return compositions(in, node, fps, mode, false) } +// Folds says whether a module the node runs inside its own composition (inOwn, Node.InOwn) is folded there at the +// fingerprint fp it is composed at: only at no fingerprint of the new form. At one (src2:) it has a composition of its +// own, composed from its build, so every reader of a build (frombus) takes the build in exactly where this says it is +// not folded (issue 1585: nats on novox, folded by the reader and composed at src2: here, refused for its server +// artifact no build had published). +func Folds(inOwn bool, fp string) bool { + return inOwn && !strings.HasPrefix(fp, delivery.SourcePrefix2) +} + func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) { reading := in reading.Nodes = append([]Node(nil), in.Nodes...) @@ -84,7 +93,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli // The private network's module is built by nobody: no fingerprint of the new form ever names it. inOwn[overlay.Name] = true folds := func(module string) bool { - return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2) + return module != "" && Folds(inOwn[module], fps[module]) } if !found { return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node) diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index daee654..5954cfc 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -191,6 +191,28 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { !strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") { t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held) } + // Issue 1585 (nats on novox, met twice): wanted at a build of the new form, a module in InOwn is no longer folded + // (compose.Folds), so its build is taken in and it composes with its image, never from the indexer's manifest as + // "not built for this mesh". + { + bb := mk("lemurs", overlay.Name, "spiegel") + bp := bb.placements["n1"] + bp.Decided.Endpoint = "n1.example:51820" + bb.placements["n1"] = bp + sp := bb.modules["spiegel"] + sp.FP2 = fp("3") + bb.modules["spiegel"] = sp + image := "registry.example:5100/spiegel/site@sha256:" + strings.Repeat("7", 64) + bb.builds["spiegel "+sp.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: sp.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "upstream", Reference: image, Digest: "sha256:" + strings.Repeat("7", 64)}}} + read, err := ComposingInput(ctx, bb, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, + map[string]string{"lemurs": fp("e"), "spiegel": sp.FP2}) + raw, _ := json.Marshal(read.Catalogue["spiegel"]) + if err != nil || read.ComposedHow.Held["spiegel"] != "" || !strings.Contains(string(raw), strings.Repeat("7", 64)) { + t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.FP2, err, + read.ComposedHow, raw) + } + } // (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter // opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index ce704e6..1fbda4b 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -234,6 +234,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module } in, err := b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted) if err == nil { + runningProvisioner(&in, s.Node, s.Running) // What runs is kept while a manifest does not yet say what shared code no longer infers (ADR 0601 §4). for _, k := range keepWhatRuns(&in, s.Node, s.Running) { if s.Kept != nil { @@ -876,7 +877,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m keptAt := maps.Clone(s.KeptAt) for _, pm := range p.Modules { if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 && - keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) { + keptAt[pm.Module] == "" && !compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) { if keptAt == nil { keptAt = map[string]string{} } @@ -891,9 +892,10 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m } for _, pm := range p.Modules { m, ok := modules[pm.Module] - if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) { - // A module it runs inside its own composition is composed there as before: no build of the new form is what - // it runs (Scope.InOwn). + if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) { + // A module it runs inside its own composition is composed there as before while it is folded: no build of the + // new form is what it runs (Scope.InOwn). Wanted at a build of the new form it is no longer folded (compose.Folds, + // the composition's own rule), so its build is taken in (issue 1585). continue } if _, refused := in.Refused[pm.Module]; refused { @@ -1230,3 +1232,38 @@ func withoutHeldData(in compose.Input, node string) compose.Input { } return out } + +// runningProvisioner names, on node, the account its provisioner client runs as now (compose.Node.RunningProvisioner): the +// account its own composition as it runs names, else the user of the running process of the module that runs the client. +func runningProvisioner(in *compose.Input, node string, running []delivery.Composed) { + account := "" + for _, c := range running { + if c.Module == "" && c.Own.Provisioner != "" { + account = c.Own.Provisioner + } + } + for _, c := range running { + if account != "" { + break + } + man, ok := in.Catalogue[c.Module] + if c.Module == "" || !ok || !man.ClaimsSeat(compose.NodeProvisionerSeat) { + continue + } + for _, raw := range c.Resources { + var r struct { + Type string `json:"type"` + User string `json:"user"` + } + if json.Unmarshal(raw, &r) == nil && r.Type == "process" && r.User != "" && r.User != "root" { + account = r.User + break + } + } + } + for i := range in.Nodes { + if in.Nodes[i].Name == node { + in.Nodes[i].RunningProvisioner = account + } + } +} diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go index e6a0311..4264ff3 100644 --- a/internal/compose/frombus/frombustest/frombustest_test.go +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -2,7 +2,9 @@ package frombustest import ( "context" + "encoding/json" "reflect" + "slices" "strings" "testing" @@ -204,6 +206,83 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { if fetched-archives != 1 { t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives) } + + // Issue 774 (ace, 2026-11-04 01:29): the provisioner client held as its stub (its build not done, so the node keeps + // running what it runs) keeps the account the node's own composition names, and restic stays handed to it with its + // plan; without it every filled write was refused and no module was handed. + if b, err = Provisioned(); err != nil { + t.Fatal(err) + } + fps, ownKey := FPs(), "" + ownKey, _ = delivery.ComposedNodeKey(Node) + resticKey, _ := delivery.ComposedKey(Node, "restic", fps["restic"]) + before, _, _, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps) + account := before[ownKey].Own.Provisioner + if err != nil || account == "" || len(before[resticKey].Filled) == 0 { + t.Fatalf("the node composes with its client: account %q, restic's filled files %v: %v", account, before[resticKey].Filled, err) + } + var running []delivery.Composed + for _, c := range before { + running = append(running, c) + } + for _, row := range []struct { + own, placed bool // the account read from the running own composition, else the client's running process; the client placed + userless bool // the client placed and not held, its process naming no user: it composes no account + }{{true, true, false}, {false, true, false}, {true, false, false}, {false, false, false}, {true, true, true}} { + own := row.own + runs := running + if !own { // what runs names no account of its own: the client's running process says it + runs = nil + for _, c := range running { + if c.Module == "" { + c.Own.Provisioner = "" + } + runs = append(runs, c) + } + } + bb, err := Provisioned() + if err != nil { + t.Fatal(err) + } + want := account + if row.userless { + // placed and not held, composing no account of its own: no stale account is kept from what runs + mod := bb.Module["mesh-provisioner-client"] + var m map[string]any + if err := json.Unmarshal(bb.Manifests[mod.Manifest], &m); err != nil { + t.Fatal(err) + } + for _, r := range m["resources"].([]any) { + delete(r.(map[string]any), "user") + } + raw, _ := json.Marshal(m) + mod.Manifest = delivery.ManifestDigest(raw) + bb.Module["mesh-provisioner-client"], bb.Manifests[mod.Manifest] = mod, raw + built := *bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] + built.Manifest = mod.Manifest + bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &built + want = "" + } else if row.placed { + // held: its build not done while it runs + bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding} + } else { + // taken out of the node's placement while what runs still names it: the account goes with it, never kept stale + p := bb.Placement[Node] + p.Modules = slices.DeleteFunc(slices.Clone(p.Modules), func(m delivery.PlacedModule) bool { return m.Module == "mesh-provisioner-client" }) + bb.Placement[Node] = p + want = "" + } + after, _, held, err := frombus.Compose(context.Background(), bb, frombus.Mesh{}, frombus.Scope{Node: Node, Running: runs}, fps) + if _, isHeld := held["mesh-provisioner-client"]; err != nil || isHeld != (row.placed && !row.userless) { + t.Fatalf("%+v: held %v: %v", row, held, err) + } + if got := after[ownKey].Own.Provisioner; got != want { + t.Errorf("%+v: the node's own names %q, want %q", row, got, want) + } + if row.placed && !row.userless && !reflect.DeepEqual(after[resticKey].Filled, before[resticKey].Filled) { + t.Errorf("%+v: restic's filled files %v, want %v", row, after[resticKey].Filled, before[resticKey].Filled) + } + } } // statesOf is the bus b as a module's read states hold it. diff --git a/internal/compose/input.go b/internal/compose/input.go index d8eae82..2898d77 100644 --- a/internal/compose/input.go +++ b/internal/compose/input.go @@ -224,6 +224,11 @@ type Node struct { // so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each // after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before. RunsOwn *delivery.Composed + // RunningProvisioner is the account the provisioner's client runs as on this node now, read from what runs (its own + // composition's provisioner account, else the user of the provisioner client's running process), empty when nothing + // says it. A client held as its stub this pass composes no process, so the node keeps this account and its handing + // (provisionerAccount; issue 774: ace, 2026-11-04 01:29, every filled write refused and postgres's consumers unhanded). + RunningProvisioner string // Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when // nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what // Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index c8db3e0..7aaae27 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -74,6 +74,12 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { // not sent the field they would refuse. r.Hands = takesHanded(node) r.Provisioner = provisionerAccount(plan) + if r.Provisioner == "" && provisionerHeld(plan) { + // The provisioner's client is placed here and held this pass (its stub, or carried as it runs, composes no process + // of its own): it keeps running as it runs, so the account it runs as stays named, and every module stays handed + // to it. A client not held that composes no account, or none placed, names none: no stale account is kept. + r.Provisioner = node.RunningProvisioner + } r.Assigned = node.Handed r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract r.JudgesRoot = node.Stated && node.Contract >= RootContract @@ -190,6 +196,21 @@ func provisionerAccount(plan catalogue.Resolution) string { return "" } +// provisionerHeld says the plan places a module claiming the provisioner client's seat on the machine that this pass +// holds (its stand-in: Stub, or Carried as it runs), which composes none of the client's process. +func provisionerHeld(plan catalogue.Resolution) bool { + for _, m := range plan.Modules { + if m.Stubbed && m.ClaimsSeat(NodeProvisionerSeat) { + return true + } + } + return false +} + +// RunsTheProvisionerClient says a module runs its machine's provisioner client (runsTheProvisionerClient), for a reader +// of what runs. +func RunsTheProvisionerClient(m catalogue.Manifest) bool { return runsTheProvisionerClient(m) } + // runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own // secret that account is delivered in. func runsTheProvisionerClient(m catalogue.Manifest) bool { diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index a8f81a0..6607fc6 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -218,6 +218,13 @@ type Pass struct { Earlier bool `json:"earlier,omitempty"` } +// LastHeld is what the last pass held, each module with why; nil before a pass or when it held none. +func (c *Composer) LastHeld() map[string]string { + c.mu.Lock() + defer c.mu.Unlock() + return maps.Clone(c.last.Held) +} + // askedWithin is how recent a pass answers an ask of the verb compose without a new one: the first wait after a seat // that did not answer (laterAgain's first). var askedWithin = 5 * time.Second @@ -915,6 +922,13 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } c.paid() pass.Held = held + // The provisioner client's account the node runs with, dropped by this pass, is said: every filled write is refused + // under a declaration that names none (filling.JudgeAccount), and no module is handed to it (issue 774, ace, + // 2026-11-04 01:29). A placed client keeps its account while it is held (compose.Node.RunningProvisioner). + if was, now := ownProvisioner(running), ownProvisioner(mapValues(compositions)); was != "" && now == "" && nodeWanted { + c.sayOnceEach(fmt.Sprintf("the node's own composition names no provisioner client's account, where what runs names %s: "+ + "once applied, every filled write is refused and no module is handed to the provisioner", was)) + } // A module refused alone is held: it keeps what it runs, its have says why, and every other module is applied (issue // 1126: one module's refusal froze the whole node). done := c.waiting("the agent marking each held module's have") @@ -1699,3 +1713,21 @@ func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string } return "", false } + +// ownProvisioner is the provisioner client's account the node's own composition among cs names, or "". +func ownProvisioner(cs []delivery.Composed) string { + for _, c := range cs { + if c.Module == "" { + return c.Own.Provisioner + } + } + return "" +} + +func mapValues(m map[string]delivery.Composed) []delivery.Composed { + out := make([]delivery.Composed, 0, len(m)) + for _, k := range slices.Sorted(maps.Keys(m)) { + out = append(out, m[k]) + } + return out +}