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
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:
1 parent
344c4f9030
commit
e15d1d30ea
15 files changed
+1116
-5
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user