package storage import ( "context" "io" "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // scriptRunner returns fixed stdout per binary name (for SMART/lvs parsing tests) and // records calls. type scriptRunner struct { out map[string][]byte // binary name -> stdout calls [][]string err error } func (s *scriptRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) { return s.Run(ctx, name, args...) } func (s *scriptRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) { s.calls = append(s.calls, append([]string{name}, args...)) return s.out[name], nil, s.err } func testStageDir() string { return filepath.Join(os.TempDir(), "felhom-test-units") } func TestHostOps_MountLifecycle(t *testing.T) { ctx := context.Background() stage := t.TempDir() unitDir := t.TempDir() rr := &recordingRunner{} ops := NewSudoHostOps(SudoHostOpsConfig{ Runner: rr, Bins: Binaries{Systemctl: "/usr/bin/systemctl", Install: "/usr/bin/install"}, UnitDir: unitDir, StageDir: stage, Logger: quietLogger(), }) // A hyphen-free mountpoint so the systemd-escaped unit filename has no backslash — the // backslash escaping is covered by TestSystemdEscapePath; here we just need a filename // that stages on the test OS (Windows treats '\' as a path separator). Production is Linux. spec := MountSpec{Name: "usb-backup", UUID: "0fc63daf-8483-4772-8e79-3d69d8477de4", Where: "/srv/felhom/bulk", FSType: "ext4"} if err := ops.EnsureMount(ctx, spec); err != nil { t.Fatalf("EnsureMount: %v", err) } // Expect: install (stage→unitDir), daemon-reload, enable --now -- . if len(rr.calls) != 3 { t.Fatalf("expected 3 commands, got %d: %v", len(rr.calls), rr.calls) } if rr.calls[0][0] != "/usr/bin/install" || !contains(rr.calls[0], "0644") { t.Errorf("call[0] not the install: %v", rr.calls[0]) } if !contains(rr.calls[1], "daemon-reload") { t.Errorf("call[1] not daemon-reload: %v", rr.calls[1]) } if !contains(rr.calls[2], "enable") || !contains(rr.calls[2], "--now") { t.Errorf("call[2] not enable --now: %v", rr.calls[2]) } // The staged unit file is keyed by UUID and uses the validated mountpoint. unitName, _ := UnitNameForMount(spec.Where) content, err := os.ReadFile(filepath.Join(stage, unitName)) if err != nil { t.Fatalf("staged unit not written: %v", err) } cs := string(content) if !strings.Contains(cs, "What=/dev/disk/by-uuid/"+spec.UUID) { t.Errorf("unit missing by-uuid What=: %s", cs) } if !strings.Contains(cs, "Where=/srv/felhom/bulk") || !strings.Contains(cs, "Type=ext4") { t.Errorf("unit missing Where/Type: %s", cs) } if !strings.Contains(cs, "WantedBy=multi-user.target") { t.Errorf("unit not enabled-persistent: %s", cs) } // Unmount (detach) = stop + disable. rr.calls = nil if err := ops.Unmount(ctx, spec.Where); err != nil { t.Fatalf("Unmount: %v", err) } if len(rr.calls) != 2 || !contains(rr.calls[0], "stop") || !contains(rr.calls[1], "disable") { t.Fatalf("Unmount should stop+disable: %v", rr.calls) } } func TestHostOps_SMART_SATA(t *testing.T) { sata := []byte(`{ "smart_status": {"passed": true}, "temperature": {"current": 38}, "power_on_time": {"hours": 12345}, "ata_smart_attributes": {"table": [ {"id": 5, "name": "Reallocated_Sector_Ct", "raw": {"value": 0}}, {"id": 197, "name": "Current_Pending_Sector", "raw": {"value": 2}}, {"id": 198, "name": "Offline_Uncorrectable", "raw": {"value": 1}} ]} }`) ops := &SudoHostOps{runner: &scriptRunner{out: map[string][]byte{"/usr/sbin/smartctl": sata}}, bins: Binaries{}.withDefaults(), logger: quietLogger()} s, err := ops.SMART(context.Background(), "/dev/sda") if err != nil { t.Fatal(err) } if s.Health != hub.SmartPassed { t.Errorf("health = %q, want PASSED", s.Health) } if got := deref(s.TemperatureC); got != 38 { t.Errorf("temp = %d", got) } if deref(s.ReallocatedSectors) != 0 || deref(s.PendingSectors) != 2 || deref(s.OfflineUncorrectable) != 1 { t.Errorf("SATA counters wrong: %+v", s) } if s.MediaErrors != nil || s.PercentageUsed != nil { t.Errorf("NVMe counters must be nil for a SATA disk") } } func TestHostOps_SMART_NVMe(t *testing.T) { nvme := []byte(`{ "smart_status": {"passed": true}, "nvme_smart_health_information_log": { "critical_warning": 0, "media_errors": 5, "percentage_used": 7, "temperature": 41 } }`) ops := &SudoHostOps{runner: &scriptRunner{out: map[string][]byte{"/usr/sbin/smartctl": nvme}}, bins: Binaries{}.withDefaults(), logger: quietLogger()} s, _ := ops.SMART(context.Background(), "/dev/nvme0n1") if s.Health != hub.SmartPassed { t.Errorf("health = %q", s.Health) } if deref(s.CriticalWarning) != 0 || deref(s.MediaErrors) != 5 || deref(s.PercentageUsed) != 7 { t.Errorf("NVMe counters wrong: %+v", s) } if deref(s.TemperatureC) != 41 { t.Errorf("nvme temp = %v", s.TemperatureC) } if s.ReallocatedSectors != nil { t.Errorf("SATA counters must be nil for an NVMe disk") } } func TestHostOps_SMART_Unsupported(t *testing.T) { // A USB-SATA bridge that exposes no SMART: smartctl returns minimal JSON (no // smart_status) and a nonzero exit. We degrade to UNKNOWN, not an error. ops := &SudoHostOps{ runner: &scriptRunner{out: map[string][]byte{"/usr/sbin/smartctl": []byte(`{"device":{"name":"/dev/sdc"}}`)}, err: errExit(2)}, bins: Binaries{}.withDefaults(), logger: quietLogger(), } s, err := ops.SMART(context.Background(), "/dev/sdc") if err != nil { t.Fatalf("unsupported SMART must degrade, not error: %v", err) } if s.Health != hub.SmartUnknown { t.Errorf("health = %q, want UNKNOWN", s.Health) } } func TestHostOps_ThinPoolMetadata(t *testing.T) { lvs := []byte(`{"report":[{"lv":[{"lv_name":"data","data_percent":"42.00","metadata_percent":"10.50"}]}]}`) ops := &SudoHostOps{runner: &scriptRunner{out: map[string][]byte{"/usr/sbin/lvs": lvs}}, bins: Binaries{}.withDefaults(), logger: quietLogger()} frac, ok := ops.ThinPoolMetadata(context.Background(), "pve", "data") if !ok { t.Fatal("expected metadata fraction") } if frac < 0.104 || frac > 0.106 { t.Errorf("metadata fraction = %v, want ~0.105", frac) } } func contains(ss []string, want string) bool { for _, s := range ss { if s == want { return true } } return false } func deref(p *int) int { if p == nil { return -1 } return *p } // errExit is a stand-in for a nonzero exit error from the runner. type errExitT int func (e errExitT) Error() string { return "exit status nonzero" } func errExit(code int) error { return errExitT(code) } // TestShouldReassertMount pins the host-reboot re-assert decision. The load-bearing case is // mounted-but-DISABLED (the live felhom-usb bug): the drive serves now, but with no wants-symlink a // host reboot would not auto-mount it, so it MUST still be re-asserted (enable --now re-creates the // symlink). Only mounted+enabled is the durable steady state we skip. func TestShouldReassertMount(t *testing.T) { cases := []struct { mounted, enabled, want bool name string }{ {true, true, false, "mounted+enabled → durable, skip"}, {true, false, true, "mounted+DISABLED → re-assert (the live bug: reboot would not auto-mount)"}, {false, true, true, "unmounted+enabled → re-assert (mount it now)"}, {false, false, true, "unmounted+disabled → re-assert (enable + mount)"}, } for _, c := range cases { if got := shouldReassertMount(c.mounted, c.enabled); got != c.want { t.Errorf("%s: shouldReassertMount(%v,%v)=%v want %v", c.name, c.mounted, c.enabled, got, c.want) } } } // TestUnitEnabled detects the WantedBy=multi-user.target wants-symlink with a privilege-free Lstat // (no systemctl subprocess), so the re-assert can tell a disabled unit from an enabled one. func TestUnitEnabled(t *testing.T) { unitDir := t.TempDir() ops := NewSudoHostOps(SudoHostOpsConfig{ Runner: &recordingRunner{}, Bins: Binaries{Systemctl: "/usr/bin/systemctl", Install: "/usr/bin/install"}, UnitDir: unitDir, StageDir: t.TempDir(), Logger: quietLogger(), }) const unit = "mnt-felhom-usb.mount" if ops.unitEnabled(unit) { t.Fatal("a unit with no wants-symlink must report NOT enabled (the felhom-usb disabled state)") } wants := filepath.Join(unitDir, "multi-user.target.wants") if err := os.MkdirAll(wants, 0o755); err != nil { t.Fatal(err) } // systemd points the wants-symlink at the real unit file; the target need not exist for Lstat. if err := os.Symlink(filepath.Join(unitDir, unit), filepath.Join(wants, unit)); err != nil { t.Skipf("symlink unsupported here (Windows privilege): %v", err) } if !ops.unitEnabled(unit) { t.Fatal("a unit WITH a wants-symlink must report enabled") } }