Detach now removes ALL stacked binds at a stable path, not just one layer, so an
eject fully detaches even with >1 bind (keeping fail-close intact).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Eject now DetachDrive's the bind under the parent but leaves the raw /mnt/<name>
mounted, so disconnect->reconnect re-binds on a non-removable drive. Tests updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The decommission unmounted the raw /mnt/<name>, orphaning a non-removable drive
so re-enroll bound an empty dir. Now DetachDrive only (bind under parent); raw
stays mounted so re-enroll re-binds. Test: raw NOT unmounted + DetachDrive called.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /disks emits guest_boot_id = <host-btime>-<guest-init-starttime>: changes on
every guest/host boot, stable across controller-only restarts. The controller
persists it + deterministically recreates drive-backed apps on change (replaces
the timed state-sample). Non-hollow parser test + companion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared-parent oneshot never ran before pve-guests (WantedBy=multi-user
wasn't pulled into the boot transaction), so on a host reboot the guest bound a
not-yet-shared parent -> private -> propagation broken. Now WantedBy=pve-guests
(pve-guests pulls it in + Before= orders it first); EnsureSharedParent reinstalls
the unit when content differs so the fix deploys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A guest's parent bind is non-recursive, so a guest reboot leaves enrolled drives
bound on the HOST but invisible in the fresh guest ns (propagation only delivers
new events). AttachDrive(vmid) now checks GuestSeesMount (/proc/<pid>/mountinfo)
and force re-binds (umount+mount) to re-propagate; a 20s periodic reconcile
self-heals guest reboots without an agent restart; BoundUnderParent reflects guest
visibility (the controller gate's signal). Caught + fixed in the live migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the per-drive 'pct set -mpN' bind with ONE permanent parent bind
/mnt/felhom-drives plus host-side felhom-data swaps underneath it (propagates
into the running guest live, no pct, no reboot; C1-immune; confined; fail-closed
when absent). EnsureSharedParent installs a boot unit ordered Before=pve-guests.
ReassertGuestBinds is now a pure host-side reconcile. /disks reports GuestPath +
BoundUnderParent for the controller repoint+gate. Non-hollow tests + companions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-start PVE hookscript (internal/guesthook) creates host-root placeholders for
absent bind-mount sources so the guest always boots (fail-closed); decommission
now pct set --delete's the dead mp (GuestBinder.DetachBind) so a missing source
can't brick the next reboot (B3 C1 bug). Non-hollow tests + companions. Installed
+ registered per-guest by the provision back-half. Transitional ahead of the
intermediary-mount re-architecture which makes C1 structural.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /disks/decommission mirrors eject (withGuest, user-data role gate) — no
operator signature, non-destructive (never formats): sets IntentDecommissioned,
prunes the GuestBindStore entry, unmounts. ReassertGuestBinds is now intent-aware
(skip non-enrolled) so a decommissioned-but-present drive never auto-rebinds on
agent restart — the load-bearing F9-reconnect fix. GuestBindStore.Remove added.
Operator-signed DecommissionExecutor + classify untouched. Non-hollow tests incl.
the intent-aware reassert companion (mutation-proven to fail on intent-blind code).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the F9 storage episode — live validation of a user-facing feature
must exercise the real flow end-to-end, not shortcut via direct
API/agent/CLI calls. Low-level mechanism tests are exempt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The format ran mkfs under the HTTP request context, so the controller's 15s client
timeout cancelled it → SIGKILL mid-write → corrupt disk. Now mkfs runs DETACHED off
s.baseCtx (a dropped request can't kill it) via a persisted formatJob record; the handler
still waits to return the synchronous result (backward-compatible with the v0.62.0
controller) but abandoning the wait on client-disconnect leaves the mkfs running to
completion. New GET /disks/format/status surfaces the job (additive). RecoverFormatJob
runs on agent startup: a record left 'running' (agent died mid-format) is re-resolved by
durable-id (anti-retarget — absent/swapped disk NOT re-formatted) and the mkfs re-run; a
blank/path-bound interrupted format is marked failed (retry), never auto-re-run.
Tests: detached run persists running→done + binds durable-id; status endpoint; recovery
re-runs an interrupted durable-id-bound format; skips blank; skips unresolvable durable-id.
Version 0.30.0 → 0.31.0.
The in-guest bind (pct set -mpN) is config state that a destroy+re-provision drops, and
nothing restored it — so a re-provisioned guest came up with its enrolled HDD unattached
(the live-drive F9 finding). New GuestBindStore persists, per guest, the durable-ids of
enrolled drives (recorded at guest-attach); ReassertGuestBinds runs on agent startup (the
host's bring-up/reconcile trigger) and re-adds any bind a guest is MISSING — but ONLY when
the durable-id still resolves to a present, mounted drive (a swapped/absent drive is never
auto-bound) and the guest lacks it (idempotent). The re-added bind activates on the guest's
next reboot, like the enroll flow. Wired in main.go (store opened beside drive-intents.json;
ReassertGuestBinds called before the local API serves).
Tests: restores a missing bind with no manual call (the operator's real-trigger proof);
skips absent/swapped durable-id; no-op when already bound; store survives reopen (restart).
F20-BUG2: the /disks list only carried DurableID in the uuid: scheme (for /disks/assign),
but the wipe gate resolves devices in the byid:/byuuid: scheme — so a customer confirming a
wipe with the advertised id was refused (binding_mismatch). Added a shared s.deviceDurableID
seam used by BOTH handleDisks (new DiskInfo.WipeDurableID) and the format gate, so the id the
customer copies from the list is exactly the id the gate accepts. DurableID (uuid:) is unchanged
(still feeds assign).
F9 (reporting half): added DiskInfo.GuestAttached — whether the drive's namespace is actually
bound into THIS guest's config (guestBoundPaths), distinct from mere host presence (State). This
is the signal whose absence made the HDD look available when it wasn't attached, and resolves the
F2 hdd_configured-vs-/disks disagreement.
Tests: wipe_durable_id is the gate scheme + distinct from uuid:; the list's wipe id matches the
gate's device-id binding (no mismatch); guest_attached true iff bound into the guest.
Expands the 'push to main; no feature branches' convention into the full rule:
report-only artifacts → felhom.eu/documentation/; risky/supervised (agent/golden/
provisioning/destructive) fixes implemented on main during the supervised session,
not prepared on a branch; unattended escape hatch = revert+report. Supersedes the old
'prepared on fix/... branch, pending review' pattern.
Closes the inline customer-confirmed wipe TOCTOU: handleDiskFormat now formats
the re-resolved device (durable id -> current device, re-derive+match, re-inspect)
via antiRetargetResolve/reresolveWipe, at parity with signedjobs.WipeExecutor.
Reviewed + approved. Agent-only host-daemon deploy (no golden rebake).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handleDiskFormat's customer-confirmed branch formatted the mutable req.Device
path; the durable id only bound the confirmation, never the mkfs target. A /dev
reassignment between inspect and mkfs could wipe the wrong physical disk.
Now mirrors signedjobs.WipeExecutor: resolve confirmed durable id -> current
device, re-derive + require exact match, re-inspect (still data-bearing), then
format THAT device. Any refusal -> 409, no mkfs. New antiRetargetResolve helper
(injected deps, unit-tested: mismatch/gone/blank/empty all refuse). Injectable
reresolveWipe seam on Server (defaults to real storage funcs).
BRANCH ONLY — pending supervised review/deploy (see AGENT-001-FIX-NOTES.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After a guest DHCP IP move, the split-horizon resolver kept serving the old IP:
the drop-in (address=/domain/ip) updated but 'systemctl reload dnsmasq' (SIGHUP)
does NOT re-read /etc/dnsmasq.d config — only /etc/hosts + cache. Changed
reload() -> restartDnsmasq() so address= changes actually take effect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docker-ce auto-starts on install with the default config; only a restart picks
up the just-written daemon.json. Same start-vs-restart trap the spike flagged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validation caught that Docker 29's default containerd-snapshotter keeps the
image store at /var/lib/containerd (on the rootfs), so mounting the data volume
at /var/lib/docker only moved named volumes — images (1.2G) stayed on the
rootfs, defeating the split. overlay2 stores images+overlay+volumes under
data-root = the data volume, which the split and the controller's statfs(/)
guard both require. Golden daemon.json now sets features.containerd-snapshotter
false + a driver guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build-golden.sh bakes a small OS rootfs + a dedicated /var/lib/docker volume
(mp0, backup=1) carrying the baked images, plus Docker log rotation. bringup.go
grows the golden's data volume to the per-customer target (DataVolGrowGB) and
emits backup=1 on data mounts (GuestMount.Backup) — closing the spike-B3 silent
DB-loss trap. CLI gains -rootfs-grow/-datavol-grow/-datavol-mount. New
RUNBOOK-provisioning-storage.md. Phase 2 = felhom-controller v0.58.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Findings report (REPORT-storage-split-spike.md) gating the provisioning
spec. Proven on throwaway LXC 9300 (destroyed): data-root on a second
local-lvm mountpoint works on an unprivileged LXC (overlayfs/ext4, no idmap,
reboot-survives); copy-not-move migration is safe. Key finding: extra CT
mountpoints are EXCLUDED from vzdump by default — need backup=1 + a CT
restart — so the docker-data mount must be attached with backup=1
(bringup.go:313 omits it today). No agent code changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PVE refuses a restore carrying mountpoint params unless rootfs is also set ("mount
points configured, but 'rootfs' not set"). Size the rootfs override from the source
rootfs (rootfsSizeGB/sizeToGB, round up). Validated manually on the host: restore of
bind-mounted 9201 with --rootfs + --mp0/--mp9 overrides boots + tears down cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A slice-10 enrolled guest's data drive is a host bind-mount mp0 that the privsep
token can't vzrestore ("bind mount is only possible for root") — so the restore-test
failed for every enrolled guest regardless of backup tier. The restore-test now reads
the source guest config (vmid from the archive volid) and passes RestoreLXC mp
overrides converting each bind-mount mpN to a throwaway 1G volume on the restore
storage (no root needed; boot-verify doesn't need the data). proxmox.RestoreLXC gains
MountOverrides. + unit tests (archiveVMID, bindMountOverrides).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- BackupConfig.BackupTarget() defaults whole-guest backup to felhom-pbs (separate
hardware = real DR), configurable via backup.local_backup_target; all NewBackupRunner
sites route through it. PBS round-trip proven live (snapshot marker + restore-test +
pct-restore) before the re-point.
- signedjobs DecommissionExecutor + ExecutorChain: makes IntentDecommissioned reachable
ONLY via a verified operator signature (keyed by the watchdog's storage durable-id);
felhom-opsign builds decommission params from -durable-id. Runner wiring moved below
the intent-store open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build-golden.sh bootstrap makes /mnt a shared mount and binds it :rslave into the
controller container so enrolled data drives (and P3 self-heal remounts) propagate
in. Scoped to /mnt (Model A: only felhom-data-namespace mounts). Spike-proven.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /disks/guest-attach binds an enrolled drive's felhom-data namespace into
the guest (Model A: felhom-data is the bind source mounted at /mnt/<name>, so
only Felhom's namespace crosses in). GuestBinder does mkdir+chown(100000)+pct set
(RW bind) via the fenced runner. Idempotent, free-slot selection, path-validated.
Spike-proven on 9201. Pairs with controller P2C + golden /mnt:rslave (P2B).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handleDiskEject now resolves the authoritative role of the storage at `where`
and refuses 403 (no Unmount) unless it is user-data. Fails safe to protected on
ambiguity. Adds roleForMountPath + an injectable HostReader seam for testability.
TestEject_RoleGated asserts protected mounts are refused with no Unmount.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>