Files
felhom.eu/documentation/audits/SPIKE-raw-drive-enroll-2026-06-15.md
T
admin f24125ecdd docs: SPIKE raw-drive plug-and-enroll (GATE, 2026-06-15)
Report-only spike + one live throwaway FORMAT experiment on /dev/sdc.
No production code, no version bump.

- Q1: raw-disk discovery gap confirmed — Observe AND localapi GET /disks
  are both built only from PVE storages (ListStorage/NodeStorage); a
  not-yet-storage disk is invisible. proxmox client has no /disks/* method.
- Q3: safety classifier (RoleForRawDevice/SystemDisks/RoleForStorage)
  already protects sda/felhom-pbs live, but needs two hardening checks:
  whole-disk-granularity enrolled-subtraction + LVM-PV membership.
- Q6: FORMAT path proven end-to-end live — pvesh disks/directory create
  -> agent Observe -> controller "Regisztralas" enroll. sdc left as the
  clean enrolled felhom-flash B3 migration target. sda/sdb untouched.
- Premise correction: felhom-pbs is remote (no local PBS disk).
- Fork list for the build spec in the audit doc + REPORT.md.
2026-06-15 10:57:33 +02:00

24 KiB
Raw Blame History

SPIKE — Self-serve raw-drive plug-and-enroll

Date: 2026-06-15 Type: GATE spike — report-only + one live throwaway experiment on /dev/sdc. Host: felhom-pve (node demo-felhom), PVE 9, guest 9201. Status: findings complete; live FORMAT path proven end-to-end; sdc left as a clean enrolled felhom-flash PVE storage (B3 migration target). No production code, no version bump. The build spec is gated on the fork picks at the end.

Goal (operator's vision): a customer plugs in a drive and enrolls it from the controller UI with no operator/Proxmox tinkering. Flow: agent detects the raw drive → decides usable-as-is vs needs-format → storage/init shows it (model/size/serial, partitions+filesystems, recommendation) → customer chooses → controller tells agent → agent creates a PVE dir storage (formatting only if chosen) → binds the felhom-data namespace into the guest (existing AttachBind) → drive usable.


One-screen summary

The flow is mostly assemblable from primitives that already exist; the genuine net-new is a raw-disk discovery source (the agent is blind to any disk that isn't already a PVE storage) and a create-storage call (the agent never creates PVE storages today). The security classifier is already written (RoleForRawDevice) and correctly protects the system disk live — but it needs two hardening additions (LVM-PV membership + whole-disk-granularity "already-enrolled" subtraction) before it can drive a discovery list.

Proven live on this host:

Disk What it is disks/list used Classifies Why
sda SSD 512G boot/root: ESP + LVM VG pve (root, swap, local-lvm thinpool, guest 9201's own disks) BIOS boot PROTECTED (system) only LVM PV (/dev/sda3); /boot/efi=sda2 pins it
sdb HDD 1T enrolled data drive felhom-usb, ext4 /mnt/felhom-usb ext4 PROTECTED (enrolled data) backs registered storage felhom-usb
sdc USB 128G throwaway flash, junk iso9660+EFI+hfsplus iso9660→(after)ext4 OFFERABLE removable, backs nothing, not a system disk

The live FORMAT experiment (Q6) turned sdc into a registered felhom-flash dir storage in one PVE API call, and it immediately appeared in the controller's agent-drive view as user-data / data-bearing / "Nem regisztrált" with a Regisztrálás button — the existing enroll path picks it up with no agent restart.

Spec premise correction: there is no local PBS disk on this host. felhom-pbs is a remote pbs: storage (server 192.168.0.180); it has no backing block device. The "PBS disk classifies PROTECTED" assertion is satisfied at the storage tier (RoleForStorageRoleBackup), not at any raw-disk tier.


Q1 — Current discovery (the gap), confirmed in source

internal/storage/observe.go:131,139Observe builds its entire view from exactly two PVE calls and nothing else:

cfg, _  := o.api.ListStorage(ctx)   // observe.go:131  → GET /storage
node, _ := o.api.NodeStorage(ctx)   // observe.go:139  → GET /nodes/{node}/storage

The proxmox client (internal/proxmox/query.go) has no raw-disk method. Full read surface: Version, Nodes, NodeStatus, ListLXC, GuestStatus, GuestConfig, ListStorage(:58), NodeStorage(:64), StorageContent, LatestBackupVolID. None call /nodes/{node}/disks/*.

Critically, the localapi disk endpoint is also sourced from Observe, not from any block-device scan:

// internal/localapi/disks.go:130 (handleDisks, GET /disks)
targets, err := s.storage.Observe(r.Context())

Conclusion: a raw /dev/sdc that is not yet a PVE storage is invisible to both the hub-facing Observe and the localapi GET /disks. A drive must already be a registered PVE storage before anything in the agent can see it. That is precisely the missing primitive. (host.Removable, observe.go:182, only annotates a storage that is already in the view — it does not discover.)

What's missing, exactly:

  1. A proxmox-client (or host-level) method to enumerate raw block devices.
  2. A discovery path in the agent that lists candidate disks (not-yet-storages) and runs the safety classifier over them.
  3. A create-storage primitive (Q5).

Q2 — Raw-disk discovery API, validated live

pvesh get /nodes/demo-felhom/disks/list --output-format json (abridged):

[
 {"devpath":"/dev/sda","type":"ssd","size":512110190592,"model":"AirDisk_512GB_SSD","serial":"QDF922W009654S30EX","used":"BIOS boot","gpt":1,"health":"PASSED","osdid":-1,"by_id_link":"/dev/disk/by-id/ata-AirDisk_512GB_SSD_QDF922W009654S30EX"},
 {"devpath":"/dev/sdb","type":"hdd","size":1000204886016,"model":"TOSHIBA_MQ04ABF100","serial":"65NOP3HDT","used":"ext4","gpt":1,"rpm":"5400"},
 {"devpath":"/dev/sdc","type":"usb","size":128320801792,"model":"Flash_Drive","serial":"0375123010001586","used":"iso9660","gpt":1,"health":"UNKNOWN"}
]

disks/list gives: devpath, by_id_link, type (ssd/hdd/usb), size, model, serial, wwn, health, gpt, osdid, used. The used enum is the single most useful built-in signal but it is insufficient alone:

  • It distinguishes sda (BIOS boot) but reports both sdb (enrolled data) and sdc (junk) as just a filesystem type (ext4 / iso9660). After enrollment sdc also reads ext4 — identical to the protected sdb. used cannot separate "offerable" from "already-enrolled data".
  • It does not report removable, nor which disk holds LVM PVs / the guest's image / the root mount.

Companion endpoints:

  • pvesh get /nodes/demo-felhom/disks/lvm → VG pve on /dev/sda3, lvcount 9. Authoritative LVM-PV-membership signal.
  • pvesh get /nodes/demo-felhom/disks/directory[] before the experiment; after, lists only felhom-flash. It does NOT list felhom-usb — PVE's directory tracking only reflects storages it created via the disks/directory API, not the agent's own systemd mounts. So disks/directory is not a reliable enumerator of dir storages.

Recommendation — discovery source: use disks/list as the candidate enumerator, cross-referenced with disks/lvm (PV membership) and the agent's existing host-mount read (SystemDisks, which already parses the mount table) and the Observe storage view (to subtract already-enrolled disks). The removable flag is already available to the agent via /sys/block/<disk>/removable (hostread.go, ProcHostReader.Removable) and via lsblk RM; PVE's disks/list does not expose it, so keep reading it host-side. A small lsblk -J is still worthwhile to get serial/by-uuid/child-partition detail per disk for the storage/init display, but the safety decision is driven by disks/list + disks/lvm + mount-table + storage-view, not by labels.


Q3 — The safety gate (security-critical core)

Good news: the classifier primitive already exists and is correct for the system disk. internal/storage/role.go:

  • SystemDisks(host) (role.go:46) — resolves the OS whole-disks from the mount table (/, /boot, /boot/efi → whole disk), root-free. On this host / is on pve-root (device-mapper, untraceable to a raw disk), but /boot/efi = sda2 pins sda exactly as the code comment intends.
  • RoleForRawDevice(device, sysDisks, sysKnown) (role.go:133) — system-vs-user-data for a raw /dev path; defaults to system on ambiguity.
  • RoleForStorage(...) (role.go:105) — tiers a registered storage; PBS→backup, USB/local-dir-on-non-system-disk→user-data, everything else→system.
  • deviceRole(...) (disks.go:836) — wraps both: prefers a matching storage target's role, else RoleForRawDevice.

Validated live (reproducing the agent's logic; sysDisks={/dev/sda} via /boot/efi=sda2, sysKnown=true):

  • RoleForRawDevice("/dev/sda")isSystemBackedsysDisks["/dev/sda"] = system ✓ PROTECTED.
  • RoleForRawDevice("/dev/sdc") → not in set → user-data ✓ offerable.
  • felhom-pbs (RoleForStorage PBS) → backup ✓ PROTECTED (storage tier; no device).

Confirmed at the UI: the controller's agent-drive view renders local=🔒system, local-lvm=🔒system, felhom-pbs=🔒backup, felhom-usb/felhom-flash=user-data.

Two hardening gaps that MUST close before this drives a discovery list

  1. Whole-disk-granularity "already-enrolled" subtraction. deviceRole matches a registered storage by exact t.BackingDevice == device (disks.go:840). The format endpoint passes the exact backing device, so this works there. But a discovery flow enumerates whole disks (/dev/sdb), while felhom-usb's backing device is the partition /dev/sdb1. The exact match fails → fall-through to RoleForRawDevice("/dev/sdb")user-data → would be offered for format, even though it holds live customer data. Fix: the offerable filter must subtract any disk whose whole-disk equals wholeDiskOf(t.BackingDevice) for any Observe target — compare at whole-disk granularity, not device-path equality.

  2. LVM-PV / guest-image membership. SystemDisks resolves the system disk via the mount table only. On this host that catches sda because the ESP is on it. But a disk that holds local-lvm PVs or guest disk images without carrying /boot//boot/efi would be missed (→ misclassified user-data). Fix: add a positive PV-membership check from disks/lvm (any disk whose partition is a PV of the local-lvm VG, or the VG that backs any guest's rootfs/mp), folding it into the protected set alongside SystemDisks.

Proposed offerable predicate (the gate)

A whole disk D is OFFERABLE iff all of:

  • RoleForRawDevice(D) == user-data (not the OS/boot disk; fail-safe system on ambiguity), and
  • D is not in the LVM-PV/guest-image protected set (disks/lvm + guest-config backing), and
  • wholeDiskOf(D) backs no registered Observe target (subtract already-enrolled, whole-disk granularity), and
  • D is not a local PBS datastore disk (here: N/A — PBS is remote).

Everything else (incl. on any ambiguity) → PROTECTED, never listed for format. This extends the existing "mkfs-not-on-data-bearing-device" discipline (disks.go:526, InspectDevice blkid+lsblk) to "mkfs-not-on-system-disk."

Non-hollow test shape

A test that FAILS if the system disk is ever offerable, fed a realistic topology fixture (sda=ESP+LVM-PV+guest images; sdb=ext4 backing a registered felhom-usb; sdc=removable junk):

TestOfferableDisks_NeverOffersProtected:
  offer := OfferableDisks(disksList, lvmView, observeTargets, hostMounts)
  assert sda  NOT in offer        // ESP + sole LVM PV  → system
  assert sdb  NOT in offer        // backs registered felhom-usb (whole-disk subtraction)
  assert sdc  IN  offer           // removable, backs nothing, not system
  // adversarial: relabel sdc's disks/list "used" to "BIOS boot" (lying label)
  //   → still offerable iff state says so; and relabel sda "used"="" (lying blank)
  //   → MUST still be protected via ESP/LVM-PV state, not the label.
  // mutation guard: drop the LVM-PV check → test MUST fail on a PV-only protected disk fixture.

The mutation clause (removing either hardening check makes the test red) is what keeps it non-hollow.


Q4 — Usable-as-is vs needs-format

Decision driven by the agent's existing InspectDevice (internal/storage/hostops.go, blkid -p -o export + lsblk -J) which already yields HasFilesystem/FSType/HasPartitionTable/HasPartitions/Mounted:

  • Enroll-as-is iff the disk presents exactly one mountable, supported data filesystem we can own — i.e. a single partition (or whole-disk fs) with FSType ∈ {ext4, xfs} (the fs types the agent's mount machinery + PVE dir storage support), not currently mounted by the host/another storage, and not the system/protected set.
  • Needs-format iff: no filesystem, an unsupported fs (the live sdc had iso9660 + a vfat ESP + hfsplus — none ownable), a partition table with multiple data partitions (ambiguous which to adopt — sdc had 4), or any signature we will not silently adopt.
  • On ambiguity → recommend format (never silently adopt an unknown layout), mirroring the destructive-on-ambiguity invariant inverted for safety: we don't destroy on ambiguity, we refuse to enroll-as-is and require an explicit format choice.

What storage/init should display per candidate:

  • Identity: model, size, serial, bus type (ssd/hdd/usb), removable badge — all from disks/list + lsblk.
  • Layout: each partition with size + detected fs (lsblk -J -o NAME,FSTYPE,SIZE,MOUNTPOINT), plus the data-bearing verdict + reason from InspectDevice.
  • Recommendation: "Enroll partition X as-is (ext4, N GB)" when the as-is predicate holds, else "Format required — current contents will be erased" with the detected junk listed (e.g. "iso9660 + EFI + hfsplus").

Q5 — PVE-storage creation via the agent — BOTH paths

FORMAT path — validated live (one call does everything)

pvesh create /nodes/demo-felhom/disks/directory \
    --name felhom-flash --device /dev/sdc --filesystem ext4 --add_storage 1

The single call ran synchronously and performed, in order:

# /sbin/sgdisk -n1 -t1:8300 /dev/sdc                  → partition sdc1
# /sbin/mkfs -t ext4 /dev/sdc1                          → fs UUID 81a26531-62d8-408d-812f-a178b1d35310
# /sbin/blkid /dev/sdc1 -o export
Created symlink …/mnt-pve-felhom\x2dflash.mount         → systemd mount unit, enabled
add_storage 1                                           → wrote storage.cfg

Resulting storage.cfg entry:

dir: felhom-flash
        path /mnt/pve/felhom-flash
        content rootdir,images,backup,iso,snippets,vztmpl
        is_mountpoint 1
        nodes demo-felhom

Resulting systemd unit (mnt-pve-felhom\x2dflash.mount):

[Mount]
What=/dev/disk/by-uuid/81a26531-62d8-408d-812f-a178b1d35310
Where=/mnt/pve/felhom-flash
Type=ext4
Options=defaults

Conventions check:

  • is_mountpoint 1 ✓ (locked convention).
  • Mount unit keyed by fs-UUID (/dev/disk/by-uuid/…) ✓ — matches the agent's own mountunit.go (What=/dev/disk/by-uuid/<UUID>).
  • Durable-id will derive uuid:81a26531-… from the storage's own fs UUID ✓ (durableid.go:deriveDurableID, USB/local-dir → uuid:).
  • Mount path = /mnt/pve/felhom-flash ✗ — diverges from the felhom /mnt/<name> convention (felhom-usb is /mnt/felhom-usb). PVE's disks/directory API has no option to override the path; it is always /mnt/pve/<name>. → FORK.
  • content = all types ✗ — felhom-usb is content backup. The data drive that holds the bind-mounted felhom-data namespace doesn't use PVE content types, but the entry should be normalized for consistency. → minor fork.

ENROLL-AS-IS path — validated by the living reference

felhom-usb itself is the enroll-as-is proof: it is a dir storage with is_mountpoint 1 at /mnt/felhom-usb, absent from disks/directory → it was created by mounting an existing fs + pvesm add dir, not by PVE's formatting disks/directory API. The agent already owns both halves:

  • EnsureMount / renderMountUnit (internal/storage/mountunit.go, HostOps.EnsureMount) — stages a by-UUID systemd .mount unit at /mnt/<name>, daemon-reload, enable --now. No mkfs.
  • the missing half is the pvesm add dir <id> --path /mnt/<name> --is_mountpoint 1 --content … call (the agent does not create PVE storages today — see Q5 net-new).

So enroll-as-is = EnsureMount(existing-fs-UUID, /mnt/<name>) + add-dir-storage — both primitives understood, one not-yet-wired.

Both paths feed the existing enroll flow

Observe iterates NodeStorage, so either path's resulting storage is picked up with no agent restart (proven for the format path below). The customer-facing enroll (AttachBind, disks.go handleDiskGuestAttach) then binds <where>/felhom-data into the guest.


Q6 — Live experiment (end-to-end, sets up B3)

Every call run against felhom-pve, all guarded by a serial-identity fence (/dev/sdc serial must equal 0375123010001586, must be removable RM=1, must have no mountpoint — abort otherwise) so sda/sdb could never be touched.

  1. Guard + wipe. Guard passed (serial matched, RM=1). wipefs -a /dev/sdc erased the iso9660 + 2×GPT + PMBR signatures; sgdisk --zap-all cleared the GPT. After: lsblk shows bare sdc (no fs), and disks/list reports sdc used=None gpt=0PVE's used=None is the "genuinely blank" signal.

  2. Create (format path). The pvesh create …/disks/directory call above — partition + mkfs ext4 + systemd mount + add_storage, one call, synchronous success (UPID:…:dircreate:felhom-flash:…).

  3. Verify storage. storage.cfg entry written (above); findmnt /mnt/pve/felhom-flash/dev/sdc1 ext4 rw; fs UUID 81a26531-…; disks/directory now lists felhom-flash (by-uuid device); disks/list sdc now used=ext4.

  4. Verify Observe. pvesh get /nodes/demo-felhom/storagefelhom-flash dir active=1 total=125704151040 (~117 GiB) — the agent's Observe source now contains it.

  5. Verify enrollable in controller. The controller settings page → "Meghajtók (ügynök nézet)" now shows:

    felhom-flashusb · /dev/sdc1 · /mnt/pve/felhom-flash · Felhasználói adat · Adatot tartalmaz · Nem regisztrált · buttons Regisztrálás / Leválasztás / Törlés…

    i.e. classified user-data, data-bearing detected, not yet enrolled, with a one-click Regisztrálás (enroll = AttachBind). End-to-end mechanism proven.

State left for B3: sdc is a clean, registered felhom-flash PVE storage (ext4, mounted /mnt/pve/felhom-flash, is_mountpoint, in the agent view, classified user-data). It is PVE-enrolled and "ready-to-enroll-into-guest" — the final AttachBind + guest reboot was intentionally not run to avoid disrupting live guest 9201; that is the one remaining step a real customer enroll performs. This is the B3 migration target.


Q7 — Wiring map (net-new vs reused)

Reused (already exists, no change)

Capability Where
System-disk resolution (mount table → whole disk) storage/role.go:46 SystemDisks
Raw-device role classifier (system vs user-data, fail-safe) storage/role.go:133 RoleForRawDevice
Registered-storage role tiering (PBS=backup, etc.) storage/role.go:105 RoleForStorage
Data-bearing inspection (blkid + lsblk) storage/hostops.go InspectDevice + disks.go:526 gate
Mount existing fs (by-UUID systemd unit at /mnt/<name>) storage/mountunit.go, HostOps.EnsureMount
Storage view the controller consumes storage/observe.go Observe (iterates ListStorage/NodeStorage)
Bind felhom-data namespace into guest + reboot localapi/guestbind.go:48 AttachBind
Enroll/assign/eject/decommission endpoints localapi/disks.go (/disks, /disks/assign, /disks/guest-attach, …)
Durable-id (uuid:) derivation storage/durableid.go deriveDurableID

Net-new

Capability Lands in Note
Raw-disk enumeration (disks/list, disks/lvm) internal/proxmox (new ListDisks, ListDisksLVM) the client has no /disks/* method today
Whole-disk wipe (wipefs/sgdisk) storage.HostOps (new) gated by the Q3 offerable predicate
Create dir storage — format path (disks/directory create) internal/proxmox (new CreateDirectoryStorage) or a host-side pvesh call in HostOps one call = partition+mkfs+mount+add_storage
Create dir storage — enroll-as-is (pvesm add dir … --is_mountpoint 1) after EnsureMount storage.HostOps (new AddDirStorage) the agent creates no PVE storages today
Raw-disk discovery + Q3 offerable filter (combine disks/list + disks/lvm + SystemDisks + Observe subtraction, whole-disk granularity) internal/storage (new OfferableDisks) + a new localapi endpoint e.g. GET /disks/raw the security core; needs the two Q3 hardening checks
New localapi endpoints: list-raw, init (format), attach-as-is internal/localapi/disks.go (new handlers) mirror the existing /disks/* handler shape + withGuest scoping
Controller storage/init UI: raw-disk list, identity/layout/recommendation, format-vs-enroll choice controller (felhom-controller) the "🔧 Új meghajtó inicializálása" / "🔗 Meglévő meghajtó csatolása" buttons already exist on the settings page as entry points

The controller-decision → agent-task → done handshake

Lives exactly where the existing disk ops live: the controller POSTs the customer's choice to a new localapi endpoint (init/format or attach-as-is), withGuest-scoped + TLS-pinned, same as POST /disks/assign / POST /disks/guest-attach today. The agent re-derives the safety verdict from device state (never trusting the controller's claim — same discipline as handleDiskFormat), executes, and the result surfaces back through Observe → the controller's agent-drive view (proven live). No new transport, no hub round-trip: this is host-agent-local, exactly like the current enroll path. Final guest usability is the existing AttachBind + reboot.


FORK LIST (operator decides before the build spec)

  1. Discovery source. disks/list + disks/lvm + agent mount-table + Observe subtraction (recommended, no extra shell-out for the safety decision) — vs additionally shelling lsblk -J for richer per-disk display. Recommendation: both — disks/list+lvm for the safety gate, lsblk for the init-screen detail.
  2. Safety-gate signals — ship vs hardened. Mount-table SystemDisks only (works on this host via the ESP) — vs +LVM-PV membership +guest-image backing +whole-disk-granularity enrolled-subtraction (required to be robust on a host where the system isn't all on one ESP-bearing disk). Recommendation: hardened — the two Q3 checks are non-negotiable for a customer-facing format gate; the mount-table-only version can offer a non-boot LVM disk.
  3. Mount-path convention. Accept PVE default /mnt/pve/<name> (free with disks/directory, but leaks PVE internals into the guest bind path) — vs roll-our-own /mnt/<name> via EnsureMount + pvesm add dir (matches felhom-usb, reuses agent machinery, but re-implements the format steps). Recommendation: /mnt/<name> for consistency with the existing enrolled drive and a clean in-guest bind path.
  4. Format-vs-enroll-as-is rules. Single supported-fs partition → enroll-as-is; everything else (no fs / unsupported / multi-partition / ambiguous) → format. Recommendation: as stated; format on any ambiguity, never silently adopt.
  5. Endpoint shapes. New GET /disks/raw (offerable list) + POST /disks/init (format path) + POST /disks/attach-existing (enroll-as-is), all withGuest-scoped, agent re-derives the gate. Recommendation: as stated, mirroring /disks/assign.
  6. v1 scope. Format-only first (the path proven live this spike), enroll-as-is in v1.1 — vs both in v1. Recommendation: format-only v1 (covers the plug-in-a-blank/junk-drive case, which is the common one), enroll-as-is fast-follow (the felhom-usb-style reuse-an-existing-ext4 case).
  7. Content normalization. Set content backup (or an explicit minimal set) on creation rather than PVE's all-types default. Recommendation: yes, normalize.
  8. content/storage create transport. Typed proxmox-client method (CreateDirectoryStorage) vs host-side pvesh in HostOps. Recommendation: typed client method, consistent with the rest of internal/proxmox.

Live cleanup state

  • sdc → registered PVE storage felhom-flash (ext4, /mnt/pve/felhom-flash, is_mountpoint, fs-UUID 81a26531-62d8-408d-812f-a178b1d35310), classified user-data, not guest-bound. Intentionally left as the B3 migration target.
  • sda, sdb untouched (serial-fenced throughout).
  • No agent/controller code changed; no version bump; no service restarted.