R-1124 + R-1012: forget a decommissioned drive that is gone; name an orphan thin device in a backup failure
gates / gates (push) Successful in 47s

R-1124: ForgetRetiredDrives (start-up + the 20 s tick) disables and removes the host mount unit of a
DECOMMISSIONED drive whose disk is absent and unmounted; the agent then no longer lists it. A present
decommissioned drive keeps its unit (one-click re-enroll). The file goes only through the new
felhom-priv-apply verb `unit-forget`, which re-checks marker, disabled, unmounted and absent as root.

R-1012: the read-only root helper felhom-thin-check <vg> <pool> (bundle) lists the pool's thin ids
(metadata snapshot reserved and released) next to LVM's before and after. BackupRunner.SetThinCheck runs
it before a snapshot-mode backup (log) and again after a failure, appending the finding in plain words to
the error the controller mails to the operator. Two reads, intersected; fails open; never removes.

Red-proofs: felhom.eu audits/agent-allowlist-rotation-2026-10-17/P1/. Code only; the CHANGELOG entry
follows the release.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
admin committed 2026-10-17 15:20:34 +02:00
1 parent 344c4f9030
commit e15d1d30ea
15 files changed
+1116 -5

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
package storage
import (
"context"
"os"
"path/filepath"
"strings"
)
// ForgetRetiredDrives (R-1124) removes the host mount unit of every DECOMMISSIONED drive that is physically gone, so a
// later host boot no longer waits for it and the agent stops listing it (RegistryKnownTargets and
// ReassertEnrolledMounts both enumerate the units — no unit, no entry, no „absent by UUID" line every 20 s).
//
// Only a drive that is BOTH decommissioned (the operator's or the customer's permanent removal) AND absent (its
// /dev/disk/by-uuid link does not resolve) AND not mounted is forgotten. A decommissioned drive that is still plugged in
// keeps its unit and its raw host mount — that is what makes the one-click re-enroll (H3) work, and decommission stays a
// logical retire (`03` §storage). The intent record stays `decommissioned`: a forgotten drive that comes back is a new
// candidate, never auto-mounted. The root half (`felhom-priv-apply unit-forget`) re-checks every one of these conditions
// and refuses otherwise. Called at agent start, on the 20-second storage tick, and right after a decommission. Best
// effort per unit; returns how many units it removed. Pinned by TestForgetRetiredDrives_*.
func (h *SudoHostOps) ForgetRetiredDrives(ctx context.Context, intent IntentReader) int {
if intent == nil {
return 0 // no intent store → we cannot know a drive was retired; forget nothing
}
entries, err := os.ReadDir(h.unitDir)
if err != nil {
h.logger.Warn("storage: forget retired drives — cannot read unit dir", "dir", h.unitDir, "err", err)
return 0
}
mounted := map[string]bool{}
if ms, merr := h.host.Mounts(); merr == nil {
for _, m := range ms {
mounted[m.MountPoint] = true
}
} else {
h.logger.Warn("storage: forget retired drives — mount table unreadable; forgetting nothing", "err", merr)
return 0
}
removed := 0
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".mount") {
continue
}
data, rerr := os.ReadFile(filepath.Join(h.unitDir, name))
if rerr != nil {
continue
}
spec, ok := parseFelhomMountUnit(string(data))
if !ok {
continue // not one of our by-UUID drive units (network units are refused by the parser)
}
durableID := "uuid:" + spec.UUID
if intent.Get(durableID) != IntentDecommissioned {
continue
}
if mounted[spec.Where] {
h.logger.Debug("storage: decommissioned drive still mounted — keeping its unit", "where", spec.Where)
continue
}
if _, derr := ResolveStorageDevice(durableID); derr == nil {
h.logger.Debug("storage: decommissioned drive is present — keeping its unit (re-enroll stays one click)", "where", spec.Where)
continue
}
if h.unitEnabled(name) {
if err := h.run(ctx, h.bins.Systemctl, "disable", "--", name); err != nil {
h.logger.Warn("storage: forget retired drive — disable failed", "where", spec.Where, "unit", name, "err", err)
continue
}
}
if err := h.run(ctx, h.bins.PrivApply, "unit-forget", name); err != nil {
h.logger.Warn("storage: forget retired drive — unit removal refused or failed", "where", spec.Where, "unit", name, "err", err)
continue
}
removed++
h.logger.Info("storage: forgot a decommissioned drive that is gone — its mount unit is removed",
"name", spec.Name, "where", spec.Where, "uuid", spec.UUID, "unit", name)
}
if removed > 0 {
if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
h.logger.Warn("storage: daemon-reload after forgetting retired drives failed", "err", err)
}
}
return removed
}
+155
View File
@@ -0,0 +1,155 @@
package storage
import (
"context"
"io"
"os"
"path/filepath"
"reflect"
"testing"
)
// forgetRunner records every privileged call and plays the root half of `felhom-priv-apply unit-forget`: the unit file
// disappears from the unit dir, so the CONSEQUENCE (the drive is no longer listed) is observable, not just the calls.
type forgetRunner struct {
unitDir string
calls [][]string
}
func (r *forgetRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
return r.Run(ctx, name, args...)
}
func (r *forgetRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
r.calls = append(r.calls, append([]string{name}, args...))
if len(args) == 2 && args[0] == "unit-forget" {
_ = os.Remove(filepath.Join(r.unitDir, args[1]))
}
return nil, nil, nil
}
type forgetCase struct {
intent DriveIntent
present bool // the by-uuid link resolves
mounted bool
enabled bool // the wants-symlink exists
mountErr bool
}
const forgetUUID = "4ebfd36d-87d3-4b63-839e-a5a710fc799a"
func runForget(t *testing.T, c forgetCase, intent IntentReader) (int, *forgetRunner, *RegistryKnownTargets, string) {
t.Helper()
unitDir := t.TempDir()
unit := "mnt-media_a.mount"
writeUnit(t, unitDir, unit, renderMountUnit(MountSpec{Name: forgetUUID, UUID: forgetUUID, Where: "/mnt/media_a", FSType: "ext4"}))
if c.enabled {
if err := os.MkdirAll(filepath.Join(unitDir, "multi-user.target.wants"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(unitDir, unit), filepath.Join(unitDir, "multi-user.target.wants", unit)); err != nil {
t.Fatal(err)
}
}
devRoot := t.TempDir()
if err := os.MkdirAll(filepath.Join(devRoot, "by-uuid"), 0o755); err != nil {
t.Fatal(err)
}
if c.present {
dev := filepath.Join(devRoot, "sdz1")
if err := os.WriteFile(dev, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(dev, filepath.Join(devRoot, "by-uuid", forgetUUID)); err != nil {
t.Fatal(err)
}
}
withDevDiskRoot(t, devRoot)
hr := &fakeHostReader{}
if c.mounted {
hr.mounts = []Mount{{MountPoint: "/mnt/media_a", FSType: "ext4"}}
}
if c.mountErr {
hr.mountsErr = os.ErrPermission
}
rr := &forgetRunner{unitDir: unitDir}
ops := NewSudoHostOps(SudoHostOpsConfig{Runner: rr, Bins: Binaries{}.withDefaults(), UnitDir: unitDir,
StageDir: t.TempDir(), Host: hr, Logger: quietLogger()})
if intent == nil {
intent = &fakeIntent{m: map[string]DriveIntent{"uuid:" + forgetUUID: c.intent}}
}
n := ops.ForgetRetiredDrives(context.Background(), intent)
return n, rr, NewRegistryKnownTargets(unitDir, intent, quietLogger()), unitDir
}
// The R-1124 case: decommissioned, then unplugged. The unit is disabled and removed through the root checker, systemd
// is reloaded, and the CONSEQUENCE — the agent no longer lists the drive — holds. RED-PROOF (P1/redproof-forget.txt):
// with ForgetRetiredDrives' body replaced by `return 0` this test fails (the drive is still listed).
func TestForgetRetiredDrives_DecommissionedAndGone_UnitRemovedAndNotListed(t *testing.T) {
n, rr, known, _ := runForget(t, forgetCase{intent: IntentDecommissioned, enabled: true}, nil)
if n != 1 {
t.Fatalf("removed = %d, want 1 (calls %v)", n, rr.calls)
}
pa := Binaries{}.withDefaults()
want := [][]string{
{pa.Systemctl, "disable", "--", "mnt-media_a.mount"},
{pa.PrivApply, "unit-forget", "mnt-media_a.mount"},
{pa.Systemctl, "daemon-reload"},
}
if !reflect.DeepEqual(rr.calls, want) {
t.Fatalf("calls = %v\nwant %v", rr.calls, want)
}
got, err := known.Known(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("the forgotten drive is still listed: %+v", got)
}
}
// Already disabled (the Tester 1 shape after a manual disable): no disable call, the removal still runs.
func TestForgetRetiredDrives_AlreadyDisabled_NoDisableCall(t *testing.T) {
n, rr, _, _ := runForget(t, forgetCase{intent: IntentDecommissioned}, nil)
pa := Binaries{}.withDefaults()
want := [][]string{{pa.PrivApply, "unit-forget", "mnt-media_a.mount"}, {pa.Systemctl, "daemon-reload"}}
if n != 1 || !reflect.DeepEqual(rr.calls, want) {
t.Fatalf("n=%d calls=%v want %v", n, rr.calls, want)
}
}
// Everything that must KEEP its unit: a decommissioned drive still plugged in (re-enroll is one click — H3), one still
// mounted, an enrolled or ejected drive that is merely absent (it comes back), a drive with no recorded intent, and an
// unreadable mount table. No privileged call at all, and the drive stays listed where it was listed.
// RED-PROOF: with the present-drive check removed, the "present" case calls unit-forget and fails.
func TestForgetRetiredDrives_KeepsEveryOtherUnit(t *testing.T) {
for name, c := range map[string]forgetCase{
"decommissioned but present": {intent: IntentDecommissioned, present: true, enabled: true},
"decommissioned but mounted": {intent: IntentDecommissioned, mounted: true},
"enrolled and absent": {intent: IntentEnrolled},
"ejected and absent": {intent: IntentEjected},
"no intent": {intent: IntentNew},
"mount table unreadable": {intent: IntentDecommissioned, mountErr: true},
} {
t.Run(name, func(t *testing.T) {
n, rr, _, unitDir := runForget(t, c, nil)
if n != 0 || len(rr.calls) != 0 {
t.Fatalf("n=%d calls=%v — this unit must be kept", n, rr.calls)
}
if _, err := os.Stat(filepath.Join(unitDir, "mnt-media_a.mount")); err != nil {
t.Fatalf("unit file gone: %v", err)
}
})
}
}
// No intent store on the host (open failure): nothing is known to be retired, so nothing is forgotten.
func TestForgetRetiredDrives_NilIntent_ForgetsNothing(t *testing.T) {
unitDir := t.TempDir()
writeUnit(t, unitDir, "mnt-media_a.mount", renderMountUnit(MountSpec{Name: "x", UUID: forgetUUID, Where: "/mnt/media_a"}))
rr := &forgetRunner{unitDir: unitDir}
ops := NewSudoHostOps(SudoHostOpsConfig{Runner: rr, UnitDir: unitDir, StageDir: t.TempDir(), Host: &fakeHostReader{}, Logger: quietLogger()})
if n := ops.ForgetRetiredDrives(context.Background(), nil); n != 0 || len(rr.calls) != 0 {
t.Fatalf("n=%d calls=%v", n, rr.calls)
}
}