diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 7738d28..4e55592 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -1092,6 +1092,9 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int decommIntent = intentStore } decommExec := signedjobs.NewDecommissionExecutor(decommIntent, logger) + if guestBindStore != nil { // typed-nil guard, as above + decommExec.SetGuestBinds(guestBindStore) // v0.159.1: prune the bind record like the self-serve decommission + } // Agent self-update (TASK D1): the agent_update executor downloads the operator-signed binary, // verifies it against the SIGNED sha256, and hands it to the root guarded wrapper (A/B flip + diff --git a/internal/signedjobs/decommission.go b/internal/signedjobs/decommission.go index 5b228bf..1445587 100644 --- a/internal/signedjobs/decommission.go +++ b/internal/signedjobs/decommission.go @@ -39,9 +39,21 @@ type decommissionParams struct { // none is wanted, because a drive can legitimately be decommissioned while physically ABSENT. type DecommissionExecutor struct { intent IntentDecommissioner + binds GuestBindPruner // optional (v0.159.1): drop the drive from every guest's bind record, as the self-serve path does logger *slog.Logger } +// GuestBindPruner is the per-guest enrolled-bind record (*localapi.GuestBindStore). The self-serve decommission prunes +// the drive from it; the signed one did not, so a forgotten drive (R-1124) left a record the 20-second guest-bind +// re-assert warned about forever ("skipping non-enrolled drive"), measured on demo-hp 2026-10-17. +type GuestBindPruner interface { + Guests() map[int][]string + Remove(vmid int, durableID string) error +} + +// SetGuestBinds wires the bind record (nil-safe: no record, nothing pruned). +func (d *DecommissionExecutor) SetGuestBinds(b GuestBindPruner) { d.binds = b } + // NewDecommissionExecutor wires the executor to the agent's intent store. func NewDecommissionExecutor(intent IntentDecommissioner, logger *slog.Logger) *DecommissionExecutor { if logger == nil { @@ -75,6 +87,20 @@ func (d *DecommissionExecutor) Execute(_ context.Context, op string, params json if err := d.intent.SetDecommissioned(p.DurableID); err != nil { return fmt.Errorf("decommission: record intent for %q: %w", p.DurableID, err) } + if d.binds != nil { + for vmid, ids := range d.binds.Guests() { + for _, id := range ids { + if id != p.DurableID { + continue + } + if err := d.binds.Remove(vmid, id); err != nil { + d.logger.Warn("decommission: guest-bind record not pruned", "vmid", vmid, "durable_id", id, "err", err) + } else { + d.logger.Info("decommission: drive removed from the guest's bind record", "vmid", vmid, "durable_id", id) + } + } + } + } d.logger.Warn("decommission: operator-signed decommission complete (drive will never auto-mount)", "durable_id", p.DurableID) return nil } diff --git a/internal/signedjobs/decommission_test.go b/internal/signedjobs/decommission_test.go index 1a39046..971b1fe 100644 --- a/internal/signedjobs/decommission_test.go +++ b/internal/signedjobs/decommission_test.go @@ -89,3 +89,37 @@ func TestExecutorChain_DispatchesToOwner(t *testing.T) { t.Errorf("want ErrNoExecutor for an unowned op, got %v", err) } } + +type fakeBinds struct{ m map[int][]string } + +func (f *fakeBinds) Guests() map[int][]string { return f.m } +func (f *fakeBinds) Remove(vmid int, id string) error { + var kept []string + for _, x := range f.m[vmid] { + if x != id { + kept = append(kept, x) + } + } + f.m[vmid] = kept + return nil +} + +// v0.159.1: the signed decommission drops the drive from EVERY guest's bind record, as the self-serve path does — and +// only that drive. RED-PROOF (felhom.eu audits/agent-allowlist-rotation-2026-10-17/P1/redproof-bindprune.txt): with +// the prune loop removed, the record still holds the drive and this test fails. +func TestDecommissionExecutor_PrunesTheGuestBindRecord(t *testing.T) { + fd := &fakeDecommissioner{} + fb := &fakeBinds{m: map[int][]string{9201: {"uuid:keep", "uuid:gone"}, 9202: {"uuid:gone"}}} + ex := NewDecommissionExecutor(fd, nil) + ex.SetGuestBinds(fb) + params, _ := json.Marshal(decommissionParams{DurableID: "uuid:gone"}) + if err := ex.Execute(context.Background(), opDecommission, params); err != nil { + t.Fatal(err) + } + if got := fb.m[9201]; len(got) != 1 || got[0] != "uuid:keep" { + t.Fatalf("9201 record = %v, want [uuid:keep]", got) + } + if got := fb.m[9202]; len(got) != 0 { + t.Fatalf("9202 record = %v, want empty", got) + } +}