diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 6e36d45..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 { @@ -1231,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..eb5eca8 100644 --- a/internal/compose/frombus/frombustest/frombustest_test.go +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -204,6 +204,48 @@ 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 _, own := range []bool{true, false} { + 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) + } + } + b.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding} + after, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, Running: runs}, fps) + if _, isHeld := held["mesh-provisioner-client"]; err != nil || !isHeld { + t.Fatalf("the client with no done build is not held: %v %v", held, err) + } + if got := after[ownKey].Own.Provisioner; got != account { + t.Errorf("the client held (account from its own composition: %v): the node's own names %q, want %q", own, got, account) + } + if !reflect.DeepEqual(after[resticKey].Filled, before[resticKey].Filled) { + t.Errorf("the client held (account from its own composition: %v): restic's filled files %v, want %v", own, 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..c6b195b 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 == "" && provisionerPlaced(plan) { + // The provisioner's client is placed here but composes no account this pass (held as its stub, which runs no + // process of its own composition): it keeps running as it runs, so the account it runs as stays named, and every + // module stays handed to it. + 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 "" } +// provisionerPlaced says the plan places a module claiming the provisioner client's seat on the machine, whether or not +// it composes the client's process this pass. +func provisionerPlaced(plan catalogue.Resolution) bool { + for _, m := range plan.Modules { + if 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 {