The internal data place, partitions as places, add_internal_place (felhom.eu architecture/16 §5-§7, §9; build B)
gates / gates (push) Successful in 27s

- internal place: the record /var/lib/felhom-agent/internal-place.json -> mounted at /mnt/belso by fs-UUID like an
  enrolled drive (startup + 20 s tick), kind "internal" in GET /disks; never takes a household drive's /mnt/belso;
  eject/decommission/format refused (dm on the system disk).
- configs/felhom-internal-place: the ruled size rule and the only lvcreate/mkfs of a system-disk volume (installer +
  the new signed op add_internal_place, operator key, no parameters).
- partitions listed per free disk (empty / filesystem / in_use); a data-bearing part's wipe id binds disk id + part
  number + fs-UUID.
- felhom-mkfs-guarded: Felhom's own sibling drive no longer blocks formatting the free part; an unlistable disk refuses.
Tests red-proved (script mutations, Go mutations, the old wrapper measured against the new tests).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
admin committed 2026-10-16 03:55:47 +02:00
1 parent e569ec6c5f
commit 912f7c127e
24 files changed
+1591 -69

No files matched your search

+2
View File
@@ -118,6 +118,8 @@ var manifest = []Capability{
{"guest-shutdown", "household power-off: guest shutdown (R-998)", "/usr/sbin/pct", []string{"shutdown", "9201", "--timeout", "120"}, false, ""},
{"host-poweroff", "household power-off: host power-off (R-998)", "/usr/bin/systemctl", []string{"poweroff"}, false, ""},
{"vzdump-partial-clean", "dead half-written vzdump removal (R-1006)", "/usr/local/sbin/felhom-vzdump-partial-clean", []string{"local", "9201", "vzdump-lxc-9201-2026_10_14-04_42_18.tar.dat"}, false, ""},
{"internal-place-plan", "internal data place: the size plan (16 §7)", "/usr/local/sbin/felhom-internal-place", []string{"plan", "0"}, false, ""},
{"internal-place-create", "internal data place: create on an old box (signed add_internal_place)", "/usr/local/sbin/felhom-internal-place", []string{"create", "0"}, false, ""},
// ---- LAN split-horizon resolver (dnsmasq) ----
{"dnsmasq-install", "dnsmasq package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "dnsmasq"}, false, ""},
@@ -72,6 +72,11 @@ var r861Injections = []string{
"/usr/local/sbin/felhom-vzdump-partial-clean local;id 9201 vzdump-lxc-9201-2026_10_14-04_42_18.tar.dat",
"/usr/local/sbin/felhom-vzdump-partial-clean local 9201",
"/usr/local/sbin/felhom-vzdump-partial-clean --help",
"/usr/local/sbin/felhom-internal-place create 132",
"/usr/local/sbin/felhom-internal-place create 0 extra",
"/usr/local/sbin/felhom-internal-place create 0;id",
"/usr/local/sbin/felhom-internal-place wipe 0",
"/usr/local/sbin/felhom-internal-place create",
}
func TestSudoersRefusesTheR861Injections(t *testing.T) {
+207
View File
@@ -0,0 +1,207 @@
package localapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// The internal data place („Belső tárhely", agent v0.158.0) — felhom.eu architecture/16-storage-physical-identity.md
// §5, §7. On a one-disk box the root helper configs/felhom-internal-place makes a second thin volume in local-lvm
// (<vg>/felhom-belso, ext4, label felhom-belso). The installer (a new box) or the signed add_internal_place job (an old
// box) then writes InternalPlaceRecord to the agent's state dir. From then on the agent treats the volume exactly like
// an enrolled drive: a Felhom mount unit by fs-UUID at /mnt/belso (EnsureMount), the `enrolled` intent, and — when the
// controller asks with its own token (POST /disks/guest-attach) — the bind at /mnt/felhom-drives/belso. So it is a
// HOST mount under the guest's mp8 parent: not in the guest config, not in vzdump, not in PBS (`16` §5).
//
// What it is NOT: a drive the household can format, wipe, eject or decommission. Its device is a device-mapper volume
// on the system disk, so the role gate (roleForMountPath → system) and the format path (ValidateBlockDevice refuses
// dm) refuse all four — pinned by internalplace_test.go. Its identity is the system disk's (build A), so a Tier-2 copy
// between it and sys_drive is never a second copy.
const (
InternalPlaceName = "belso"
InternalPlaceWhere = "/mnt/belso"
// InternalPlaceFile is the record's name inside the agent's state dir.
InternalPlaceFile = "internal-place.json"
)
var reFSUUID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// InternalPlaceRecord is the state file the maker writes once the volume exists and is formatted.
type InternalPlaceRecord struct {
FSUUID string `json:"fs_uuid"`
GiB int `json:"gib,omitempty"`
MadeBy string `json:"made_by,omitempty"` // "installer" | "signed-job"
MadeAt string `json:"made_at,omitempty"`
}
// ReadInternalPlace reads the record; ok=false when there is none (the common case: a box with no internal place).
func ReadInternalPlace(file string) (InternalPlaceRecord, bool, error) {
var rec InternalPlaceRecord
b, err := os.ReadFile(file)
if errors.Is(err, os.ErrNotExist) {
return rec, false, nil
}
if err != nil {
return rec, false, err
}
if err := json.Unmarshal(b, &rec); err != nil {
return rec, false, fmt.Errorf("internal place record %s: %w", file, err)
}
if !reFSUUID.MatchString(rec.FSUUID) {
return rec, false, fmt.Errorf("internal place record %s: fs_uuid %q is not a filesystem UUID", file, rec.FSUUID)
}
return rec, true, nil
}
// WriteInternalPlace writes the record atomically (0600).
func WriteInternalPlace(file string, rec InternalPlaceRecord) error {
if !reFSUUID.MatchString(rec.FSUUID) {
return fmt.Errorf("internal place: fs_uuid %q is not a filesystem UUID", rec.FSUUID)
}
b, err := json.MarshalIndent(rec, "", " ")
if err != nil {
return err
}
tmp := file + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, file)
}
// internalPlaceState memoises what EnsureInternalPlace last saw, so the 20-second tick logs a state CHANGE once and
// does no work on the steady state.
type internalPlaceState struct {
mu sync.Mutex
done string // the fs-UUID that is mounted and recorded enrolled
lastWhy string // the last reason it could not be ensured (logged once per change)
}
// internalPlaceUUID returns the configured internal place's fs-UUID, or "" when this box has none (or the record is
// unreadable — logged by EnsureInternalPlace).
func (s *Server) internalPlaceUUID() string {
if s.internalPlaceFile == "" {
return ""
}
rec, ok, err := ReadInternalPlace(s.internalPlaceFile)
if err != nil || !ok {
return ""
}
return rec.FSUUID
}
// isInternalPlace reports whether a host path is this box's internal place: its raw mount (/mnt/belso) or its bind
// under the guests' shared parent (/mnt/felhom-drives/belso — the path POST /disks/same-disk judges).
func (s *Server) isInternalPlace(hostPath string) bool {
p := path.Clean(hostPath)
return (p == InternalPlaceWhere || p == StableParentDir+"/"+InternalPlaceName) && s.internalPlaceUUID() != ""
}
// EnsureInternalPlace mounts the internal place on the host at /mnt/belso by fs-UUID and records it `enrolled`,
// when this box has one. Idempotent and cheap on the steady state; safe on the 20-second tick. It never formats,
// never unmounts, and never mounts anything at /mnt/belso that is not the recorded UUID: a household drive that
// happens to be named „belso" keeps its mount, and the internal place waits (logged once).
func (s *Server) EnsureInternalPlace(ctx context.Context) {
if s.internalPlaceFile == "" {
return
}
rec, ok, err := ReadInternalPlace(s.internalPlaceFile)
if err != nil {
s.internalPlaceNote("record unreadable: " + err.Error())
return
}
if !ok {
return
}
uuid := rec.FSUUID
st := &s.ipState
st.mu.Lock()
done := st.done == uuid
st.mu.Unlock()
// What is at /mnt/belso now, if anything?
mountedUUID, mounted := "", false
if mounts, merr := s.hostReader().Mounts(); merr == nil {
for _, m := range mounts {
if path.Clean(m.MountPoint) == InternalPlaceWhere {
mounted = true
if u, okU := s.hostReader().ResolveUUID(m.Device); okU {
mountedUUID = u
}
}
}
}
if mounted && mountedUUID != "" && mountedUUID != uuid {
s.internalPlaceNote("another filesystem (uuid " + mountedUUID + ") is mounted at " + InternalPlaceWhere + " — the internal place waits")
return
}
// A Felhom unit for /mnt/belso that names another UUID (a household drive called „belso", unplugged): never
// overwrite it — that would silently take the drive's name and mount point.
if s.driveTargets != nil {
if drives, derr := s.driveTargets.Known(ctx); derr == nil {
for _, d := range drives {
if path.Clean(d.MountPath) == InternalPlaceWhere && d.UUID != "" && d.UUID != uuid {
s.internalPlaceNote("a drive unit for " + InternalPlaceWhere + " names another uuid (" + d.UUID + ") — the internal place waits")
return
}
}
}
}
if mounted && mountedUUID == uuid {
if !done {
s.recordIntent(ctx, InternalPlaceWhere, "enrolled")
st.mu.Lock()
st.done, st.lastWhy = uuid, ""
st.mu.Unlock()
s.logger.Info("internal place: mounted and enrolled", "where", InternalPlaceWhere, "fs_uuid", uuid, "gib", rec.GiB)
}
return
}
if s.disks == nil {
s.internalPlaceNote("disk management is not configured on this host")
return
}
if s.resolveStorageDevice != nil {
if _, rerr := s.resolveStorageDevice("uuid:" + uuid); rerr != nil {
s.internalPlaceNote("its volume is not present (uuid " + uuid + ")")
return
}
}
start := time.Now()
if err := s.disks.EnsureMount(ctx, storage.MountSpec{Name: InternalPlaceName, UUID: uuid, Where: InternalPlaceWhere, FSType: "ext4"}); err != nil {
s.internalPlaceNote("mount failed: " + err.Error())
return
}
s.recordIntent(ctx, InternalPlaceWhere, "enrolled")
st.mu.Lock()
st.done, st.lastWhy = uuid, ""
st.mu.Unlock()
s.logger.Info("internal place: mounted on the host", "where", InternalPlaceWhere, "fs_uuid", uuid, "gib", rec.GiB,
"duration_ms", time.Since(start).Milliseconds())
}
func (s *Server) internalPlaceNote(why string) {
st := &s.ipState
st.mu.Lock()
changed := st.lastWhy != why
st.lastWhy, st.done = why, ""
st.mu.Unlock()
if changed {
s.logger.Warn("internal place: not ensured", "why", why)
} else {
s.logger.Debug("internal place: still not ensured", "why", why)
}
}
// InternalPlaceFileIn is the record's path inside a state dir.
func InternalPlaceFileIn(stateDir string) string { return filepath.Join(stateDir, InternalPlaceFile) }
+249
View File
@@ -0,0 +1,249 @@
package localapi
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"os/exec"
"path/filepath"
"runtime"
"sync"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// The internal data place (felhom.eu architecture/16 §5, agent v0.158.0): mounted like an enrolled drive, but never
// at the cost of a household drive, never formatted, ejected or decommissioned, and the same disk as sys_drive.
const ipUUID = "0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c"
const ipDev = "/dev/mapper/pve-felhom--belso"
// ipHost is a HostReader whose mount table the test changes, and whose by-uuid map it controls.
type ipHost struct {
mu sync.Mutex
mounts []storage.Mount
uuids map[string]string // device → fs-UUID
}
func (h *ipHost) Mounts() ([]storage.Mount, error) {
h.mu.Lock()
defer h.mu.Unlock()
return append([]storage.Mount(nil), h.mounts...), nil
}
func (h *ipHost) ResolveUUID(dev string) (string, bool) { u, ok := h.uuids[dev]; return u, ok }
func (h *ipHost) DeviceExists(string) bool { return true }
func (h *ipHost) Rotational(string) (bool, bool) { return false, false }
func (h *ipHost) Removable(string) (bool, bool) { return false, false }
func (h *ipHost) BlockSlaves(string) ([]string, bool) { return nil, false }
func newIPHost(extra ...storage.Mount) *ipHost {
return &ipHost{
// root on a whole-disk partition (as sysOnSDA): the system disk resolves, so a USB drive is user-data.
mounts: append([]storage.Mount{{Device: "/dev/sda1", MountPoint: "/", FSType: "ext4"}}, extra...),
uuids: map[string]string{ipDev: ipUUID, "/dev/sdb1": "99999999-aaaa-bbbb-cccc-dddddddddddd"},
}
}
type ipRig struct {
srv *Server
disks *fakeDiskOps
intent *fakeIntent
}
func newIPRig(t *testing.T, host *ipHost, known []storage.KnownTarget, record bool) ipRig {
t.Helper()
dir := t.TempDir()
file := filepath.Join(dir, InternalPlaceFile)
if record {
if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: ipUUID, GiB: 183, MadeBy: "installer"}); err != nil {
t.Fatal(err)
}
}
d, in := &fakeDiskOps{}, newFakeIntent()
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{},
Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200}, Disks: d, DiskGate: &fakeGate{},
HostReader: host, Intent: in, DriveTargets: fakeKnownTargets{drives: known},
InternalPlaceFile: file, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
srv.resolveStorageDevice = func(id string) (string, error) {
if id == "uuid:"+ipUUID {
return "/dev/dm-7", nil
}
return "", errors.New("absent")
}
return ipRig{srv, d, in}
}
func TestInternalPlace_NoRecord_DoesNothing(t *testing.T) {
r := newIPRig(t, newIPHost(), nil, false)
r.srv.EnsureInternalPlace(context.Background())
if len(r.disks.mountCalls) != 0 {
t.Fatalf("no record: no mount may be made, got %+v", r.disks.mountCalls)
}
}
func TestInternalPlace_Record_MountsByUUIDAtMntBelso(t *testing.T) {
r := newIPRig(t, newIPHost(), nil, true)
r.srv.EnsureInternalPlace(context.Background())
if len(r.disks.mountCalls) != 1 {
t.Fatalf("want exactly one EnsureMount, got %+v", r.disks.mountCalls)
}
got := r.disks.mountCalls[0]
if got.UUID != ipUUID || got.Where != InternalPlaceWhere || got.FSType != "ext4" || got.Name != InternalPlaceName {
t.Fatalf("EnsureMount spec: %+v", got)
}
}
func TestInternalPlace_AlreadyMounted_EnrolledOnceNoRemount(t *testing.T) {
host := newIPHost(storage.Mount{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"})
r := newIPRig(t, host, nil, true)
r.srv.EnsureInternalPlace(context.Background())
r.srv.EnsureInternalPlace(context.Background())
if len(r.disks.mountCalls) != 0 {
t.Fatalf("already mounted: no EnsureMount, got %+v", r.disks.mountCalls)
}
if r.intent.Get("uuid:"+ipUUID) != storage.IntentEnrolled {
t.Fatalf("the internal place must be recorded enrolled")
}
}
// A household drive called „belso" keeps its mount point: the internal place never takes it.
// Positive control: TestInternalPlace_Record_MountsByUUIDAtMntBelso (the same record, /mnt/belso free).
func TestInternalPlace_NeverTakesAHouseholdDriveNamedBelso(t *testing.T) {
mounted := newIPHost(storage.Mount{Device: "/dev/sdb1", MountPoint: InternalPlaceWhere, FSType: "ext4"})
r := newIPRig(t, mounted, nil, true)
r.srv.EnsureInternalPlace(context.Background())
if len(r.disks.mountCalls) != 0 || r.intent.Get("uuid:"+ipUUID) != "" {
t.Fatalf("a foreign filesystem at /mnt/belso: nothing may be mounted or recorded, got %+v", r.disks.mountCalls)
}
unplugged := []storage.KnownTarget{{Name: "belso", UUID: "99999999-aaaa-bbbb-cccc-dddddddddddd", MountPath: InternalPlaceWhere, MountBacked: true}}
r2 := newIPRig(t, newIPHost(), unplugged, true)
r2.srv.EnsureInternalPlace(context.Background())
if len(r2.disks.mountCalls) != 0 {
t.Fatalf("an unplugged household drive's unit for /mnt/belso must not be overwritten, got %+v", r2.disks.mountCalls)
}
}
func TestInternalPlace_VolumeAbsent_NoMount(t *testing.T) {
r := newIPRig(t, newIPHost(), nil, true)
r.srv.resolveStorageDevice = func(string) (string, error) { return "", errors.New("absent") }
r.srv.EnsureInternalPlace(context.Background())
if len(r.disks.mountCalls) != 0 {
t.Fatalf("absent volume: no mount, got %+v", r.disks.mountCalls)
}
}
// The household cannot eject or decommission it: its device is a device-mapper volume on the system disk.
// Positive control: the same calls for a whole-disk USB drive (/dev/sdb1) are not refused by role.
func TestInternalPlace_EjectAndDecommissionRefused(t *testing.T) {
host := newIPHost(
storage.Mount{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"},
storage.Mount{Device: "/dev/sdb1", MountPoint: "/mnt/hdd_1", FSType: "ext4"},
)
r := newIPRig(t, host, nil, true)
h := r.srv.Handler()
for _, route := range []string{"/disks/eject", "/disks/decommission"} {
if w := do(t, h, "POST", route, "A", `{"where":"/mnt/belso"}`); w.Code != http.StatusForbidden {
t.Fatalf("%s /mnt/belso: want 403, got %d %s", route, w.Code, w.Body.String())
}
}
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/hdd_1"}`); w.Code == http.StatusForbidden {
t.Fatalf("positive control: a USB drive's eject must not be refused by role, got %d %s", w.Code, w.Body.String())
}
for _, u := range r.disks.unmountCalls {
if u == InternalPlaceWhere {
t.Fatalf("the internal place must never be unmounted, got %+v", r.disks.unmountCalls)
}
}
}
// The consequence `16` §5 needs: a copy between the internal place and sys_drive is the SAME disk (build A then never
// counts it), and the place is reported as kind internal.
type ipIdentHost struct{ fakeIdentHost }
func (h *ipIdentHost) KernelName(dev string) (string, bool) {
if dev == ipDev {
return "dm-7", true
}
return h.fakeIdentHost.KernelName(dev)
}
func (h *ipIdentHost) Slaves(n string) []string {
if n == "dm-7" {
return []string{"dm-3"} // felhom-belso → the thin pool
}
if n == "dm-3" {
return []string{"sda3"} // the pool → the system disk's LVM partition
}
return nil
}
func TestInternalPlace_IsTheSystemDiskAndKindInternal(t *testing.T) {
ih := &ipIdentHost{fakeIdentHost{
virt: "none",
parent: map[string]string{"sda3": "sda"},
props: map[string]map[string]string{"sda": {"ID_BUS": "ata", "ID_MODEL": "SanDisk", "ID_SERIAL_SHORT": "1"}},
mounts: []storage.Mount{
{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"},
{Device: "/dev/mapper/pve-root", MountPoint: "/mnt/felhom-drives", FSType: "ext4"},
{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"},
{Device: ipDev, MountPoint: "/mnt/felhom-drives/belso", FSType: "ext4"},
},
}}
fg := &fakeGuestsCfg{mounts: map[int]map[string]string{8200: {
"mp0": "local-lvm:vm-8200-disk-1,mp=/var/lib/felhom,backup=1,size=100G",
"mp8": "/mnt/felhom-drives,mp=/mnt/felhom-drives",
}}}
dir := t.TempDir()
file := filepath.Join(dir, InternalPlaceFile)
if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: ipUUID}); err != nil {
t.Fatal(err)
}
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
Storage: fakeStorage{targets: []hub.StorageTarget{provenSSD}}, Tokens: staticTokens{"A": 8200},
HostReader: fakeHostReader{mounts: ih.mounts}, Identity: &storage.IdentityResolver{Host: ih},
InternalPlaceFile: file, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
got, code := sameDisk(t, srv.Handler(), "/mnt/felhom-drives/belso/felhom-data/jellyfin", "/mnt/sys_drive")
if code != http.StatusOK || got.Different || !got.Proven || got.Reason != "same_disk" {
t.Fatalf("internal place vs sys_drive: want same disk, proven; got %d %+v", code, got)
}
if got.A.Kind != storage.PlaceKindInternal {
t.Fatalf("kind: want %q, got %q", storage.PlaceKindInternal, got.A.Kind)
}
}
func TestInternalPlaceRecord_RefusesANonUUID(t *testing.T) {
file := filepath.Join(t.TempDir(), InternalPlaceFile)
if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: "../../etc"}); err == nil {
t.Fatal("a non-UUID must not be written")
}
}
// The root helper's own tests (configs/test_felhom_internal_place.py — the ruled size table, never formatting what
// holds anything) run with the Go suite.
func TestInternalPlaceHelperPythonSuite(t *testing.T) {
py, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 not found")
}
_, file, _, _ := runtime.Caller(0)
script := filepath.Join(filepath.Dir(file), "..", "..", "configs", "test_felhom_internal_place.py")
out, err := exec.Command(py, script).CombinedOutput()
if err != nil {
t.Fatalf("helper suite failed: %v\n%s", err, out)
}
}
+2
View File
@@ -135,6 +135,8 @@ func (s *Server) ownMountIdentity(hostPath string) (storage.PlaceIdentity, strin
}
kind := storage.PlaceKindDisk
switch {
case s.isInternalPlace(hostPath): // v0.158.0 `16` §5: the one-disk box's data place, on the system disk
kind = storage.PlaceKindInternal
case id.Reason == storage.ReasonNetwork:
kind = storage.PlaceKindNetwork
case strings.HasPrefix(m.Device, "/dev/") && !strings.HasPrefix(m.Device, "/dev/mapper/") && isPartitionPath(m.Device):
+49 -43
View File
@@ -208,6 +208,9 @@ type Options struct {
// Intent records drive enroll/eject intent for the self-heal watchdog (slice 10 P3). OPTIONAL —
// when nil, no intent is recorded (self-heal runs ungated).
Intent IntentRecorder
// InternalPlaceFile (v0.158.0, felhom.eu `16` §5) is the path of the internal data place record
// (InternalPlaceRecord). OPTIONAL — "" means this host has no internal place and EnsureInternalPlace is a no-op.
InternalPlaceFile string
// GuestBinds persists which user-data drives (by durable-id) are enrolled into each guest, so the
// startup re-assert (ReassertGuestBinds) can restore a bind that a re-provision dropped (F9).
// OPTIONAL — when nil, guest binds are not recorded and the startup re-assert is a no-op.
@@ -316,12 +319,14 @@ type Server struct {
// identity blob from the hub, unseal it with the customer's recovery code, return ONLY the
// offsite repository password. OPTIONAL — nil (no hub client configured) makes
// POST /escrow/recover-offsite-password answer 503 rather than pretending.
escrowRecovery EscrowRecoverer
intent IntentRecorder // slice 10 P3 (optional)
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
staleLock StaleLockController // F2-b startup stale-lock recovery (optional)
partialSweep func(ctx context.Context, vmid int) // R-1006 start-up sweep of dead vzdump leftovers (optional)
escrowRecovery EscrowRecoverer
intent IntentRecorder // slice 10 P3 (optional)
internalPlaceFile string // v0.158.0 `16` §5 (optional)
ipState internalPlaceState // EnsureInternalPlace's memo
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
staleLock StaleLockController // F2-b startup stale-lock recovery (optional)
partialSweep func(ctx context.Context, vmid int) // R-1006 start-up sweep of dead vzdump leftovers (optional)
// guestPower (F-REBOOT) is per-guest start-attempt state for the guest-power watchdog.
// Guarded by guestPowerMu in guestpower.go; in-memory on purpose (see guestPowerState).
guestPower map[int]guestPowerState
@@ -455,43 +460,44 @@ func NewServer(o Options) (*Server, error) {
cadence = defaultBackupCadence
}
s := &Server{
addr: o.ListenAddr,
cert: o.Cert,
guests: o.Guests,
backups: o.Backups,
store: o.Store,
storage: o.Storage,
driveTargets: o.DriveTargets,
smart: o.Smart,
tokens: o.Tokens,
cadence: cadence,
logger: o.Logger,
now: func() time.Time { return time.Now().UTC() },
disks: o.Disks,
diskGate: o.DiskGate,
guestList: o.Guests2,
guestAttach: o.GuestAttach,
mem: o.Memory,
netStorage: o.NetStorage,
privileged: o.Privileged,
configPath: o.ConfigPath,
stateDir: o.StateDir,
netMountRoot: storage.NetworkMountRoot,
smbCredsDir: o.SmbCredsDir,
escrowStagePath: o.EscrowStagePath,
escrowRecovery: o.EscrowRecovery,
intent: o.Intent,
guestBinds: o.GuestBinds,
formatJobs: o.FormatJobs,
staleLock: o.StaleLock,
host: o.HostReader,
ident: o.Identity,
hostMetrics: o.HostMetrics,
hostID: o.HostID,
agentVersion: o.AgentVersion,
logRing: o.LogRing,
jobs: map[backupJobKey]*backupJob{},
swapInFlight: map[int]bool{},
addr: o.ListenAddr,
cert: o.Cert,
guests: o.Guests,
backups: o.Backups,
store: o.Store,
storage: o.Storage,
driveTargets: o.DriveTargets,
smart: o.Smart,
tokens: o.Tokens,
cadence: cadence,
logger: o.Logger,
now: func() time.Time { return time.Now().UTC() },
disks: o.Disks,
diskGate: o.DiskGate,
guestList: o.Guests2,
guestAttach: o.GuestAttach,
mem: o.Memory,
netStorage: o.NetStorage,
privileged: o.Privileged,
configPath: o.ConfigPath,
stateDir: o.StateDir,
netMountRoot: storage.NetworkMountRoot,
smbCredsDir: o.SmbCredsDir,
escrowStagePath: o.EscrowStagePath,
escrowRecovery: o.EscrowRecovery,
intent: o.Intent,
internalPlaceFile: o.InternalPlaceFile,
guestBinds: o.GuestBinds,
formatJobs: o.FormatJobs,
staleLock: o.StaleLock,
host: o.HostReader,
ident: o.Identity,
hostMetrics: o.HostMetrics,
hostID: o.HostID,
agentVersion: o.AgentVersion,
logRing: o.LogRing,
jobs: map[backupJobKey]*backupJob{},
swapInFlight: map[int]bool{},
}
// R-82 tier resolution. Options.BackupTiers is authoritative when supplied; otherwise ONE tier
// is synthesized from Backups + BackupCadence — the pre-R-82 shape, so every existing caller
+6 -1
View File
@@ -64,6 +64,11 @@ const (
// The config bundle (agent v0.143.0, R-840, `11` §5.4.2): the box's ROOT-OWNED files (sudoers, wrappers, units).
// Destructive-class (signed, operational key) like agent_update; the root wrapper re-verifies the signature itself.
ClassAgentConfigUpdate OpClass = "agent_config_update"
// The internal data place on an OLD one-disk box (agent v0.158.0, felhom.eu `16` §9): thin space on the system
// disk, made by the root helper felhom-internal-place. Destructive-class (signed, operational key): it is an
// operator act, never a household button, and no agent-internal provenance may start it.
ClassAddInternalPlace OpClass = "add_internal_place"
)
// Disposition is the classifier verdict.
@@ -132,7 +137,7 @@ func Classify(class OpClass, prov Provenance) Disposition {
return Destructive
case ClassKeyRotation:
return Destructive
case ClassAgentUpdate, ClassOSDockerStep, ClassOSPVEStep, ClassOSKernelStep, ClassAgentConfigUpdate:
case ClassAgentUpdate, ClassOSDockerStep, ClassOSPVEStep, ClassOSKernelStep, ClassAgentConfigUpdate, ClassAddInternalPlace:
// Never benign — no agent-internal provenance can make replacing the agent binary
// unsigned-safe (a compromised process must not be able to self-bless an update).
return Destructive
+91
View File
@@ -0,0 +1,91 @@
package signedjobs
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/localapi"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// opAddInternalPlace is the op class this executor serves (mirrors reconcile.ClassAddInternalPlace).
const opAddInternalPlace = "add_internal_place"
// InternalPlaceHelper is the root helper's path (configs/felhom-internal-place, from the config bundle).
const InternalPlaceHelper = "/usr/local/sbin/felhom-internal-place"
var reHelperResult = regexp.MustCompile(`^(created|existing|formatted) uuid=([0-9a-fA-F-]{36}) gib=([0-9]+)$`)
// InternalPlaceExecutor adds the internal data place to an OLD one-disk box (felhom.eu architecture/16 §9: „one-disk
// boxes get no internal place by themselves; adding one later is a signed operator job, not a household button").
// It runs the SAME root helper the installer runs — `felhom-internal-place create 0` (0 GiB planned: the guest's
// volumes already exist and are counted) — writes the record the agent mounts from, and asks the local API to mount
// it now. Every safety rule (the ruled size, never format what holds anything) lives in the helper, as root.
//
// The op carries no parameters: the size is the rule's, never the signer's. Destructive-class by default (the
// classifier's fail-safe; ClassAddInternalPlace documents it): it takes thin space on the system disk, so only an
// operator signature may start it.
type InternalPlaceExecutor struct {
runner proxmox.Runner
recordFile string
ensure func(context.Context) // late-bound: the local API server is built after the jobs runner
logger *slog.Logger
}
// NewInternalPlaceExecutor wires the executor. ensure may be nil at construction and set later through SetEnsure.
func NewInternalPlaceExecutor(runner proxmox.Runner, recordFile string, logger *slog.Logger) *InternalPlaceExecutor {
if logger == nil {
logger = slog.Default()
}
return &InternalPlaceExecutor{runner: runner, recordFile: recordFile, logger: logger}
}
// SetEnsure late-binds the mount step (localapi.Server.EnsureInternalPlace).
func (e *InternalPlaceExecutor) SetEnsure(f func(context.Context)) { e.ensure = f }
// Execute implements signedjobs.Executor for add_internal_place.
func (e *InternalPlaceExecutor) Execute(ctx context.Context, op string, params json.RawMessage) error {
if op != opAddInternalPlace {
return ErrNoExecutor
}
if p := bytes.TrimSpace(params); len(p) > 0 && !bytes.Equal(p, []byte("{}")) && !bytes.Equal(p, []byte("null")) {
return fmt.Errorf("add_internal_place: takes no parameters (the size is the ruled rule's), got %s", p)
}
if e.runner == nil || e.recordFile == "" {
return fmt.Errorf("add_internal_place: not wired on this host (no privileged runner or state dir)")
}
start := time.Now()
e.logger.Warn("add_internal_place: executing operator-signed job (the internal data place, `16` §9)")
out, stderr, err := e.runner.Run(ctx, InternalPlaceHelper, "create", "0")
line := strings.TrimSpace(string(out))
if err != nil {
return fmt.Errorf("add_internal_place: helper refused: %v: %s", err, strings.TrimSpace(string(stderr)))
}
if strings.HasPrefix(line, "skipped ") {
// Not an error the operator can retry away: the pool has no room by the ruled rule. Fail the job with the
// helper's own numbers so the hub shows why.
return fmt.Errorf("add_internal_place: %s", line)
}
m := reHelperResult.FindStringSubmatch(line)
if m == nil {
return fmt.Errorf("add_internal_place: unexpected helper output %q", line)
}
gib, _ := strconv.Atoi(m[3])
rec := localapi.InternalPlaceRecord{FSUUID: m[2], GiB: gib, MadeBy: "signed-job", MadeAt: time.Now().UTC().Format(time.RFC3339)}
if err := localapi.WriteInternalPlace(e.recordFile, rec); err != nil {
return fmt.Errorf("add_internal_place: writing the record: %w", err)
}
if e.ensure != nil {
e.ensure(ctx)
}
e.logger.Warn("add_internal_place: complete", "result", m[1], "fs_uuid", m[2], "gib", gib,
"duration_ms", time.Since(start).Milliseconds())
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package signedjobs
import (
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/localapi"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
)
// add_internal_place (felhom.eu architecture/16 §9, agent v0.158.0): the operator's job for an OLD one-disk box.
type ipRunner struct {
out, stderr string
err error
calls [][]string
}
func (r *ipRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
r.calls = append(r.calls, append([]string{name}, args...))
return []byte(r.out), []byte(r.stderr), r.err
}
func (r *ipRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
return r.Run(ctx, name, args...)
}
func ipExec(t *testing.T, r *ipRunner) (*InternalPlaceExecutor, string, *int) {
t.Helper()
file := filepath.Join(t.TempDir(), localapi.InternalPlaceFile)
e := NewInternalPlaceExecutor(r, file, slog.New(slog.NewTextHandler(io.Discard, nil)))
n := 0
e.SetEnsure(func(context.Context) { n++ })
return e, file, &n
}
func TestAddInternalPlace_RunsTheHelperWritesTheRecordAndMounts(t *testing.T) {
r := &ipRunner{out: "created uuid=0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c gib=33\n"}
e, file, ensured := ipExec(t, r)
if err := e.Execute(context.Background(), "add_internal_place", []byte(`{}`)); err != nil {
t.Fatal(err)
}
if len(r.calls) != 1 || strings.Join(r.calls[0], " ") != InternalPlaceHelper+" create 0" {
t.Fatalf("the helper must be called exactly as `create 0`, got %v", r.calls)
}
rec, ok, err := localapi.ReadInternalPlace(file)
if err != nil || !ok || rec.FSUUID != "0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c" || rec.GiB != 33 || rec.MadeBy != "signed-job" {
t.Fatalf("record: %+v ok=%v err=%v", rec, ok, err)
}
if *ensured != 1 {
t.Fatalf("the mount must be asked for once, got %d", *ensured)
}
}
func TestAddInternalPlace_NoRoomOrRefusalOrGarbage_WritesNothing(t *testing.T) {
for name, r := range map[string]*ipRunner{
"skipped": {out: "skipped only 7 GiB free for it (pool 53, reserve 16, promised 102, planned 0; needs 20)"},
"refused": {err: errors.New("exit status 1"), stderr: "felhom-internal-place: REFUSED: holds something"},
"garbage": {out: "created uuid=../../etc gib=1"},
} {
e, file, ensured := ipExec(t, r)
if err := e.Execute(context.Background(), "add_internal_place", nil); err == nil {
t.Fatalf("%s: want an error", name)
}
if _, statErr := os.Stat(file); !os.IsNotExist(statErr) || *ensured != 0 {
t.Fatalf("%s: no record and no mount may follow, stat=%v ensured=%d", name, statErr, *ensured)
}
}
}
func TestAddInternalPlace_TakesNoParametersAndIgnoresOtherOps(t *testing.T) {
r := &ipRunner{out: "created uuid=0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c gib=33"}
e, _, _ := ipExec(t, r)
if err := e.Execute(context.Background(), "add_internal_place", []byte(`{"gib":900}`)); err == nil || len(r.calls) != 0 {
t.Fatalf("a size from the signer must be refused before the helper runs, err=%v calls=%v", err, r.calls)
}
if err := e.Execute(context.Background(), "decommission", []byte(`{}`)); !errors.Is(err, ErrNoExecutor) {
t.Fatalf("another op: want ErrNoExecutor, got %v", err)
}
}
func TestAddInternalPlace_IsSignatureOnly(t *testing.T) {
if got := reconcile.Classify(reconcile.ClassAddInternalPlace, reconcile.Provenance{}); got != reconcile.Destructive {
t.Fatalf("add_internal_place must need an operator signature, classified %v", got)
}
if string(reconcile.ClassAddInternalPlace) != opAddInternalPlace {
t.Fatalf("op string drift: %q vs %q", reconcile.ClassAddInternalPlace, opAddInternalPlace)
}
}
+9 -5
View File
@@ -13,14 +13,16 @@ import (
// (guarded, Impl-1) and/or mounts + binds. Blank disks are "initialize"-only; disks already carrying a
// mountable FS are ALSO "attach" candidates.
type CandidateDisk struct {
Device string `json:"device"` // whole disk, e.g. /dev/sdd
SizeBytes int64 `json:"size_bytes"` // 0 when unreadable
Device string `json:"device"` // whole disk, e.g. /dev/sdd
SizeBytes int64 `json:"size_bytes"` // 0 when unreadable
Model string `json:"model,omitempty"`
FSType string `json:"fstype,omitempty"` // first filesystem found ("" = blank)
DataBearing bool `json:"data_bearing"` // has any FS / partition (→ wipe-confirm downstream)
Mountable bool `json:"mountable"` // carries an ext4/xfs FS the agent can mount as-is
FSType string `json:"fstype,omitempty"` // first filesystem found ("" = blank)
DataBearing bool `json:"data_bearing"` // has any FS / partition (→ wipe-confirm downstream)
Mountable bool `json:"mountable"` // carries an ext4/xfs FS the agent can mount as-is
MountSource string `json:"mount_source,omitempty"` // the node to mount for attach (e.g. /dev/sdd1)
DurableID string `json:"durable_id,omitempty"` // "uuid:<fs-uuid>" if it has an FS; "" for a blank disk
// Partitions (v0.158.0, felhom.eu `16` §6): each partition of this disk as its own place — see partitions.go.
Partitions []CandidatePartition `json:"partitions,omitempty"`
}
// mountableFSTypes are the filesystems the agent can mount as-is (EnsureMount) → an "attach" candidate.
@@ -83,6 +85,8 @@ func (h *SudoHostOps) buildCandidate(dev, name string) CandidateDisk {
if len(nodes) > 1 {
c.DataBearing = true
}
c.Partitions = candidatePartitions(lout, name, readSysPartSize, readSysPartNumber, h.host.ResolveUUID,
felhomOwnedMounts(dev, nodes, h.mountTable))
}
}
return c
+34 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
@@ -24,8 +25,13 @@ var devDiskRoot = "/dev/disk"
const (
durableByID = "byid:"
durableByUUID = "byuuid:"
// partFSUUIDSep joins a partition's by-id link and its fs-UUID in one wipe id (v0.158.0, `16` §6).
partFSUUIDSep = "+fsuuid:"
)
// rePartLink matches a by-id link of a PARTITION (udev appends -part<N> to the disk's link).
var rePartLink = regexp.MustCompile(`-part[1-9][0-9]{0,2}$`)
// byIDPriority ranks /dev/disk/by-id link prefixes most-stable-first: wwn (hardware world-wide
// name) > nvme-eui (NVMe EUI) > nvme-/ata-/scsi-/usb- (model+serial). dm-/lvm-/md- names are
// excluded (they are mapper constructs, not the physical disk we want to bind a wipe to).
@@ -41,6 +47,14 @@ func DeviceDurableID(device string) (string, error) {
}
// 1. best by-id link pointing at this device.
if name := bestByIDLink(target); name != "" {
// v0.158.0 (felhom.eu `16` §6): a PARTITION's link already carries the disk id + the part number
// (wwn-…-part2). When the part holds a filesystem, its fs-UUID is bound too, so a part that was reformatted —
// or another disk's part2 that took the link — derives a different id and the wipe refuses.
if rePartLink.MatchString(name) {
if uuid := byUUIDFor(target); uuid != "" {
return durableByID + name + partFSUUIDSep + uuid, nil
}
}
return durableByID + name, nil
}
// 2. fallback: a filesystem UUID symlink.
@@ -56,10 +70,29 @@ func ResolveDurableDevice(durableID string) (string, error) {
switch {
case strings.HasPrefix(durableID, durableByID):
name := strings.TrimPrefix(durableID, durableByID)
fsUUID := ""
if i := strings.Index(name, partFSUUIDSep); i >= 0 {
name, fsUUID = name[:i], name[i+len(partFSUUIDSep):]
if !rePartLink.MatchString(name) || !safeLinkName(fsUUID) {
return "", fmt.Errorf("storage: unsafe durable id %q", durableID)
}
}
if !safeLinkName(name) {
return "", fmt.Errorf("storage: unsafe durable id %q", durableID)
}
return filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-id", name))
dev, err := filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-id", name))
if err != nil || fsUUID == "" {
return dev, err
}
// Both halves must name the SAME device now (`16` §6): the part by its disk id + number, and its filesystem.
byFS, ferr := filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-uuid", fsUUID))
if ferr != nil {
return "", fmt.Errorf("storage: durable id %q: its filesystem %s no longer resolves: %w", durableID, fsUUID, ferr)
}
if byFS != dev {
return "", fmt.Errorf("storage: durable id %q: the part (%s) and its filesystem (%s) are different devices now", durableID, dev, byFS)
}
return dev, nil
case strings.HasPrefix(durableID, durableByUUID):
uuid := strings.TrimPrefix(durableID, durableByUUID)
if !safeLinkName(uuid) {
@@ -0,0 +1,24 @@
package storage
import (
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// The root mkfs wrapper's own tests (configs/test_felhom_mkfs_guarded.py, agent v0.158.0 — a sibling partition that
// is Felhom's own drive no longer blocks formatting the part next to it; every other mount still refuses) run with
// the Go suite.
func TestMkfsGuardedPythonSuite(t *testing.T) {
py, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 not found")
}
_, file, _, _ := runtime.Caller(0)
script := filepath.Join(filepath.Dir(file), "..", "..", "configs", "test_felhom_mkfs_guarded.py")
out, err := exec.Command(py, script).CombinedOutput()
if err != nil {
t.Fatalf("wrapper suite failed: %v\n%s", err, out)
}
}
+112
View File
@@ -0,0 +1,112 @@
package storage
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// The data-bearing partition path binds the disk id + the part number + the fs-UUID (felhom.eu architecture/16 §6,
// agent v0.158.0). The wipe re-resolve (localapi antiRetargetResolveExpect) formats only when resolve(id) succeeds AND
// DeviceDurableID(that device) == id — the round trip these tests pin, in the three ways a target can move.
type partFixture struct{ base, root string }
func newPartFixture(t *testing.T) partFixture {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("symlink fixtures run on Linux")
}
f := partFixture{base: t.TempDir()}
f.root = filepath.Join(f.base, "disk")
for _, d := range []string{"sdb", "sdb1", "sdb2", "sdc2"} {
if err := os.WriteFile(filepath.Join(f.base, d), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
os.MkdirAll(filepath.Join(f.root, "by-id"), 0o755)
os.MkdirAll(filepath.Join(f.root, "by-uuid"), 0o755)
withDevDiskRoot(t, f.root)
return f
}
func (f partFixture) link(t *testing.T, sub, name, dev string) {
t.Helper()
p := filepath.Join(f.root, sub, name)
os.Remove(p)
if err := os.Symlink(filepath.Join(f.base, dev), p); err != nil {
t.Fatal(err)
}
}
func (f partFixture) dev(name string) string { return filepath.Join(f.base, name) }
func (f partFixture) standard(t *testing.T) {
f.link(t, "by-id", "wwn-0x5000aaaa", "sdb")
f.link(t, "by-id", "ata-TOSHIBA_MQ04_65NOP-part1", "sdb1")
f.link(t, "by-id", "wwn-0x5000aaaa-part1", "sdb1")
f.link(t, "by-id", "wwn-0x5000aaaa-part2", "sdb2")
f.link(t, "by-uuid", "1111-films", "sdb2")
}
func roundTrip(id string) (string, bool) {
dev, err := ResolveDurableDevice(id)
if err != nil {
return "", false
}
again, err := DeviceDurableID(dev)
return dev, err == nil && again == id
}
func TestPartitionWipeID_BindsDiskPartAndFilesystem(t *testing.T) {
f := newPartFixture(t)
f.standard(t)
id, err := DeviceDurableID(f.dev("sdb2"))
if err != nil || id != "byid:wwn-0x5000aaaa-part2+fsuuid:1111-films" {
t.Fatalf("a part with a filesystem: want disk id + part + fs-UUID, got %q %v", id, err)
}
if dev, ok := roundTrip(id); !ok || dev != f.dev("sdb2") {
t.Fatalf("the unchanged part must round-trip, got %q %v", dev, ok)
}
// A blank part (no filesystem) and a whole disk keep the plain by-id binding.
if id1, _ := DeviceDurableID(f.dev("sdb1")); id1 != "byid:wwn-0x5000aaaa-part1" {
t.Fatalf("a blank part: want the plain part link, got %q", id1)
}
if idDisk, _ := DeviceDurableID(f.dev("sdb")); idDisk != "byid:wwn-0x5000aaaa" {
t.Fatalf("a whole disk keeps today's id, got %q", idDisk)
}
}
func TestPartitionWipeID_ReformattedPartRefuses(t *testing.T) {
f := newPartFixture(t)
f.standard(t)
id, _ := DeviceDurableID(f.dev("sdb2"))
os.Remove(filepath.Join(f.root, "by-uuid", "1111-films"))
f.link(t, "by-uuid", "2222-other", "sdb2") // someone reformatted the part since the household confirmed
if _, ok := roundTrip(id); ok {
t.Fatalf("a reformatted part must not round-trip (the wipe would hit a different filesystem)")
}
}
func TestPartitionWipeID_LinkMovedToAnotherDiskRefuses(t *testing.T) {
f := newPartFixture(t)
f.standard(t)
id, _ := DeviceDurableID(f.dev("sdb2"))
f.link(t, "by-id", "wwn-0x5000aaaa-part2", "sdc2") // a different node took the part link; the fs is still sdb2
if _, err := ResolveDurableDevice(id); err == nil || !strings.Contains(err.Error(), "different devices") {
t.Fatalf("the part link and its filesystem on different devices must refuse, got %v", err)
}
}
func TestPartitionWipeID_RefusesAForgedCompound(t *testing.T) {
f := newPartFixture(t)
f.standard(t)
f.link(t, "by-uuid", "3333-whole", "sdb") // a filesystem straight on the whole disk: both halves name sdb
for _, id := range []string{"byid:wwn-0x5000aaaa+fsuuid:3333-whole", "byid:wwn-0x5000aaaa-part2+fsuuid:../x"} {
if _, err := ResolveDurableDevice(id); err == nil {
t.Fatalf("%q must be refused", id)
}
}
}
+104
View File
@@ -0,0 +1,104 @@
package storage
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
)
// Partitions as places (agent v0.158.0) — felhom.eu architecture/16-storage-physical-identity.md §6.
//
// A disk that passed the WHOLE-disk unclaimed filter (classifyClaim: not the system disk, no LVM/ZFS/RAID/LUKS/swap
// member on any node, no foreign mount on any node) also lists its partitions, each with its own state:
//
// empty no filesystem, no children → „format this part" (self-serve: the agent's probe says blank)
// filesystem ext4/xfs (mountable) or another fs → „use this part" when mountable; „format this part" only through
// the customer-confirmed data-bearing path (the agent's own probe)
// in_use already Felhom's own drive → shown, never offered (it is a registered drive already)
//
// A partition with children (LVM, a mapper) is not listed. The design's separate „a filesystem with no files" state is
// folded into `filesystem` on purpose: telling the two apart needs a root read-only mount, and the data-bearing path's
// confirmation is the safer default for both (`16` §13). Creating, deleting or resizing a partition: never here (D5).
// The format binding is the partition's own by-id link (…-partN: the disk id + the partition number) via
// DeviceDurableID, re-resolved before mkfs (wipe_reresolve.go); the attach key is its fs-UUID.
type CandidatePartition struct {
Device string `json:"device"` // e.g. /dev/sdb2
Number int `json:"number,omitempty"`
SizeBytes int64 `json:"size_bytes"`
FSType string `json:"fstype,omitempty"`
State string `json:"state"` // empty | filesystem | in_use
Mountable bool `json:"mountable"`
DurableID string `json:"durable_id,omitempty"` // uuid:<fs-uuid> — the attach key ("" when empty)
MountedAt string `json:"mounted_at,omitempty"` // in_use: Felhom's own mount
}
const (
PartEmpty = "empty"
PartFilesystem = "filesystem"
PartInUse = "in_use"
)
// candidatePartitions is the pure lister: the whole disk's lsblk tree → its partitions. sizeOf/numberOf read /sys,
// uuidOf the by-uuid links, owned is the set of mountpoints corroborated as Felhom's own (felhomOwnedMounts).
func candidatePartitions(lsblkJSON []byte, wholeName string, sizeOf func(string) int64, numberOf func(string) int,
uuidOf func(string) (string, bool), owned map[string]bool) []CandidatePartition {
var doc struct {
BlockDevices []lsblkDev `json:"blockdevices"`
}
if json.Unmarshal(lsblkJSON, &doc) != nil {
return nil
}
var out []CandidatePartition
for _, d := range doc.BlockDevices {
if d.Name != wholeName {
continue
}
for _, p := range d.Children {
if len(p.Children) > 0 || memberFSTypes[p.FSType] {
continue // LVM/mapper on it, or a member: not a place (the disk filter refuses these anyway)
}
c := CandidatePartition{Device: "/dev/" + p.Name, Number: numberOf(p.Name), SizeBytes: sizeOf(p.Name), FSType: p.FSType}
switch {
case p.MountPoint != "" && (underFelhomDrives(p.MountPoint) || owned[p.MountPoint]):
c.State, c.MountedAt = PartInUse, p.MountPoint
case p.MountPoint != "":
continue // mounted by something else: never offered (the disk filter refuses the disk anyway)
case p.FSType == "":
c.State = PartEmpty
default:
c.State = PartFilesystem
c.Mountable = mountableFSTypes[p.FSType]
if u, ok := uuidOf(c.Device); ok && u != "" {
c.DurableID = "uuid:" + u
}
}
out = append(out, c)
}
}
return out
}
// readSysPartNumber reads /sys/class/block/<part>/partition (0 when unreadable).
func readSysPartNumber(name string) int {
b, err := os.ReadFile(filepath.Join("/sys/class/block", name, "partition"))
if err != nil {
return 0
}
n, _ := strconv.Atoi(strings.TrimSpace(string(b)))
return n
}
// readSysPartSize reads a partition's size in bytes from /sys/class/block/<part>/size (512-byte sectors).
func readSysPartSize(name string) int64 {
b, err := os.ReadFile(filepath.Join("/sys/class/block", name, "size"))
if err != nil {
return 0
}
s, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
if err != nil {
return 0
}
return s * 512
}
+73
View File
@@ -0,0 +1,73 @@
package storage
import (
"strings"
"testing"
)
// Partitions as places (felhom.eu architecture/16 §6, agent v0.158.0): every partition of a free disk with its own
// state; Felhom's own drive shown but never offered; anything foreign, layered or a member never listed.
const partTree = `{"blockdevices":[
{"name":"sdb","fstype":null,"mountpoint":null,"children":[
{"name":"sdb1","fstype":"ext4","mountpoint":"/mnt/felhom-drives/hdd_1"},
{"name":"sdb2","fstype":null,"mountpoint":null},
{"name":"sdb3","fstype":"ntfs","mountpoint":null},
{"name":"sdb4","fstype":"xfs","mountpoint":null},
{"name":"sdb5","fstype":"ext4","mountpoint":"/mnt/films"},
{"name":"sdb6","fstype":"ext4","mountpoint":"/srv/data"},
{"name":"sdb7","fstype":"LVM2_member","mountpoint":null,"children":[{"name":"vg-lv","fstype":"ext4","mountpoint":null}]},
{"name":"sdb8","fstype":"crypto_LUKS","mountpoint":null}
]},
{"name":"sdc","fstype":null,"mountpoint":null,"children":[{"name":"sdc1","fstype":null,"mountpoint":null}]}
]}`
func listParts(owned map[string]bool) map[string]CandidatePartition {
got := candidatePartitions([]byte(partTree), "sdb",
func(n string) int64 { return int64(len(n)) << 30 },
func(n string) int { return int(n[len(n)-1] - '0') },
func(dev string) (string, bool) { return "uuid-of-" + strings.TrimPrefix(dev, "/dev/"), true },
owned)
m := map[string]CandidatePartition{}
for _, p := range got {
m[p.Device] = p
}
return m
}
func TestPartitions_StatesPerPartition(t *testing.T) {
m := listParts(map[string]bool{"/mnt/films": true})
want := map[string]string{"/dev/sdb1": PartInUse, "/dev/sdb2": PartEmpty, "/dev/sdb3": PartFilesystem,
"/dev/sdb4": PartFilesystem, "/dev/sdb5": PartInUse}
if len(m) != len(want) {
t.Fatalf("want exactly %d listed partitions, got %+v", len(want), m)
}
for dev, st := range want {
if m[dev].State != st {
t.Fatalf("%s: want %s, got %+v", dev, st, m[dev])
}
}
if m["/dev/sdb2"].DurableID != "" || m["/dev/sdb2"].Mountable {
t.Fatalf("an empty part has no attach key: %+v", m["/dev/sdb2"])
}
if !m["/dev/sdb4"].Mountable || m["/dev/sdb4"].DurableID != "uuid:uuid-of-sdb4" || m["/dev/sdb4"].Number != 4 {
t.Fatalf("an xfs part is usable by its fs-UUID: %+v", m["/dev/sdb4"])
}
if m["/dev/sdb3"].Mountable {
t.Fatalf("ntfs is a filesystem the agent does not mount as-is: %+v", m["/dev/sdb3"])
}
}
// A raw /mnt/<name> mount counts as Felhom's own ONLY when corroborated (felhomOwnedMounts); else it is foreign and
// the part is not listed. Positive control: TestPartitions_StatesPerPartition (the same mount, corroborated).
func TestPartitions_UncorroboratedMountIsNotOffered(t *testing.T) {
m := listParts(nil)
if _, ok := m["/dev/sdb5"]; ok {
t.Fatalf("an uncorroborated /mnt/films mount must not be listed, got %+v", m["/dev/sdb5"])
}
for _, dev := range []string{"/dev/sdb6", "/dev/sdb7", "/dev/sdb8", "/dev/sdc1"} {
if _, ok := m[dev]; ok {
t.Fatalf("%s must never be listed (foreign mount, LVM, LUKS, another disk)", dev)
}
}
}