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.
24 KiB
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/initshows it (model/size/serial, partitions+filesystems, recommendation) → customer chooses → controller tells agent → agent creates a PVE dir storage (formatting only if chosen) → binds thefelhom-datanamespace into the guest (existingAttachBind) → 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 (RoleForStorage → RoleBackup), not at any raw-disk tier.
Q1 — Current discovery (the gap), confirmed in source
internal/storage/observe.go:131,139 — Observe 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:
- A proxmox-client (or host-level) method to enumerate raw block devices.
- A discovery path in the agent that lists candidate disks (not-yet-storages) and runs the safety classifier over them.
- 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 readsext4— identical to the protected sdb.usedcannot 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→ VGpveon/dev/sda3,lvcount 9. Authoritative LVM-PV-membership signal.pvesh get /nodes/demo-felhom/disks/directory→[]before the experiment; after, lists onlyfelhom-flash. It does NOT listfelhom-usb— PVE's directory tracking only reflects storages it created via thedisks/directoryAPI, not the agent's own systemd mounts. Sodisks/directoryis 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 onpve-root(device-mapper, untraceable to a raw disk), but/boot/efi=sda2pinssdaexactly as the code comment intends.RoleForRawDevice(device, sysDisks, sysKnown)(role.go:133) — system-vs-user-data for a raw/devpath; 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, elseRoleForRawDevice.
Validated live (reproducing the agent's logic; sysDisks={/dev/sda} via /boot/efi=sda2, sysKnown=true):
RoleForRawDevice("/dev/sda")→isSystemBacked→sysDisks["/dev/sda"]= system ✓ PROTECTED.RoleForRawDevice("/dev/sdc")→ not in set → user-data ✓ offerable.felhom-pbs(RoleForStoragePBS) → 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
-
Whole-disk-granularity "already-enrolled" subtraction.
deviceRolematches a registered storage by exactt.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), whilefelhom-usb's backing device is the partition/dev/sdb1. The exact match fails → fall-through toRoleForRawDevice("/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 equalswholeDiskOf(t.BackingDevice)for anyObservetarget — compare at whole-disk granularity, not device-path equality. -
LVM-PV / guest-image membership.
SystemDisksresolves the system disk via the mount table only. On this host that catchessdabecause the ESP is on it. But a disk that holdslocal-lvmPVs or guest disk images without carrying/boot//boot/efiwould be missed (→ misclassified user-data). Fix: add a positive PV-membership check fromdisks/lvm(any disk whose partition is a PV of thelocal-lvmVG, or the VG that backs any guest'srootfs/mp), folding it into the protected set alongsideSystemDisks.
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), andDis not in the LVM-PV/guest-image protected set (disks/lvm+ guest-config backing), andwholeDiskOf(D)backs no registeredObservetarget (subtract already-enrolled, whole-disk granularity), andDis 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 fromInspectDevice. - 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 ownmountunit.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-usbis/mnt/felhom-usb). PVE'sdisks/directoryAPI has no option to override the path; it is always/mnt/pve/<name>. → FORK. content= all types ✗ —felhom-usbiscontent backup. The data drive that holds the bind-mountedfelhom-datanamespace 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.mountunit 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.
-
Guard + wipe. Guard passed (serial matched, RM=1).
wipefs -a /dev/sdcerased the iso9660 + 2×GPT + PMBR signatures;sgdisk --zap-allcleared the GPT. After:lsblkshows baresdc(no fs), anddisks/listreportssdc used=None gpt=0— PVE'sused=Noneis the "genuinely blank" signal. -
Create (format path). The
pvesh create …/disks/directorycall above — partition + mkfs ext4 + systemd mount +add_storage, one call, synchronous success (UPID:…:dircreate:felhom-flash:…). -
Verify storage.
storage.cfgentry written (above);findmnt /mnt/pve/felhom-flash→/dev/sdc1 ext4 rw; fs UUID81a26531-…;disks/directorynow lists felhom-flash (by-uuid device);disks/listsdc nowused=ext4. -
Verify Observe.
pvesh get /nodes/demo-felhom/storage→felhom-flash dir active=1 total=125704151040(~117 GiB) — the agent'sObservesource now contains it. -
Verify enrollable in controller. The controller settings page → "Meghajtók (ügynök nézet)" now shows:
felhom-flash —
usb · /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)
- Discovery source.
disks/list+disks/lvm+ agent mount-table + Observe subtraction (recommended, no extra shell-out for the safety decision) — vs additionally shellinglsblk -Jfor richer per-disk display. Recommendation: both — disks/list+lvm for the safety gate, lsblk for the init-screen detail. - Safety-gate signals — ship vs hardened. Mount-table
SystemDisksonly (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. - Mount-path convention. Accept PVE default
/mnt/pve/<name>(free withdisks/directory, but leaks PVE internals into the guest bind path) — vs roll-our-own/mnt/<name>viaEnsureMount+pvesm add dir(matchesfelhom-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. - 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.
- Endpoint shapes. New
GET /disks/raw(offerable list) +POST /disks/init(format path) +POST /disks/attach-existing(enroll-as-is), allwithGuest-scoped, agent re-derives the gate. Recommendation: as stated, mirroring/disks/assign. - 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). - Content normalization. Set
content backup(or an explicit minimal set) on creation rather than PVE's all-types default. Recommendation: yes, normalize. content/storage create transport. Typed proxmox-client method (CreateDirectoryStorage) vs host-sidepveshinHostOps. Recommendation: typed client method, consistent with the rest ofinternal/proxmox.
Live cleanup state
sdc→ registered PVE storagefelhom-flash(ext4,/mnt/pve/felhom-flash, is_mountpoint, fs-UUID81a26531-62d8-408d-812f-a178b1d35310), classified user-data, not guest-bound. Intentionally left as the B3 migration target.sda,sdbuntouched (serial-fenced throughout).- No agent/controller code changed; no version bump; no service restarted.