DR recipe slice (controller half), grounded in SPIKE-dr-recipe-2026-06-16. The
controller emitter is the BOUNDARY enforcement point: v1 ships an explicit
allowlist {catalog_ref, enabled, storage_bindings} and reads NOTHING from
AppConfig.Env, so no ENC:/token/password can leak. storage_bindings parsed from
the compose (${HDD_PATH}/${USERDATA_PATH} volume binds -> {container_path,
drive, subpath}).
Load-bearing tests: TestBuildAppRecipe_NoSecrets (synthetic-secret app -> none
leak) + TestBuildAppRecipe_AllowlistIsLoadBearing (red-proof companion) +
TestAppStorageBindings + TestBuildDRRecipeAppHalf. Red-proofed live: forcing the
emitter to dump Env makes the boundary test fail. recipe_version=1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
processGuestBootChange recreated the drive-backed app stacks but never re-synced
FileBrowser (base-infra, no HDD_PATH), so its drive mounts went stale after a
reboot. Now, AFTER pollLiveBinds confirms the live binds and the apps are
recreated, trigger go s.SyncFileBrowserMounts() so FileBrowser converges against
the now-live drives. Refactored into pure recreateDriveBackedApps(stacks, present,
recreate, syncFB). Tests: FB sync runs once after recreate (red-proofed companion);
runs even when nothing recreated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Found FileBrowser down after the host-reboot drills (my 'all recovered' claim only
checked the 8 felhom-flash apps). Two distinct gaps:
(A) agent-side: felhom-usb did not re-mount after the host-reboot device-letter
swap (mount unit inactive; agent reports present drive as durable-id absent) —
a felhom-agent bug, out of controller scope.
(B) controller-side: FileBrowser is base-infra (no HDD_PATH) so processGuestBootChange
skips it; its SyncFileBrowserMounts runs once at startup, racing the bind, not
retried. Recommended fix: call SyncFileBrowserMounts after the live-bind poll.
Recovered FileBrowser live (started felhom-usb mount unit -> agent bound it ->
restarted controller -> FB Up healthy). felhom-usb userdata was intact, just hidden
behind the unmounted placeholder. Corrected the host-reboot trust caveat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-drilled the HOST reboot (felhom-pve) x2 after the follow-up challenge: both
recover all 8 drive-backed apps automatically; host-btime prefix advances so the
host path triggers processGuestBootChange; the boot-race manifests on host reboots
too (not just guest). Corrected the REPORT's 'host path unaffected' claim.
Regression analysis: the v0.68 sweep genuinely exercised the recovery (it surfaced
a real state-filter bug on the host reboot), so the agent path worked then and
regressed after (controller.yaml reset to golden no-local_api baseline on each
recreate; old MaybeIngest never re-merged). v0.70.0 config round-trip exonerated
(GET /api/config is verbatim). ensureLocalAPI closes the regression on both paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live diagnosis of drive-backed apps stuck Exited after a pct reboot pinned THREE
sub-causes, fixed together (hardening the existing processGuestBootChange, not a
parallel mechanism):
1. Agent-path blocker (live root cause): agentClient() returned "agent not
configured" (cfg.LocalAPI.Endpoint empty), so processGuestBootChange AND the
whole drive gate bailed at the first guard. bootstrap.json had a complete
local_api block, but MaybeIngest returned immediately on "already configured"
so a controller.yaml seeded before local_api existed never got the agent path.
Fix: MaybeIngest now calls ensureLocalAPI on the already-configured path,
merging local_api from bootstrap.json into the existing controller.yaml when
missing (no hub re-pull, config preserved; idempotent + fail-safe).
2. Boot-race readiness gate: processGuestBootChange sampled BoundUnderParent once
during fast startup, racing the ~18s rebind, recreated nothing, burned its
boot-id one-shot. Fix: gate on the REAL live in-guest bind -- driveBindLive
checks /mnt/felhom-drives/<drive> is a mountpoint in the controller's own /mnt
rslave /proc/self/mountinfo; pollLiveBinds waits for it (bounded ~120s) before
recreating via the normal pipeline. shouldRecreateOnBoot stays state-independent
so stuck-Exited create-time-failure apps are included.
3. Single-shot fragility: processGuestBootChange ran only once at startup; a
briefly-unreachable agent right after a guest reboot stranded recovery. Fix:
driveGateLoop runs it every periodic tick too (idempotent, boot-id gated).
Tests (non-hollow, pre-fix companions, red-proofed): pollLiveBinds waits then
reports live / never-live stays absent / single early sample misses; ensureLocalAPI
merges local_api into a configured controller.yaml that lacks it / no-ops when
present. Live-accepted with repeated pct reboot 9201.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sub-cause: on guest pct reboot, in-guest dockerd auto-starts unless-stopped
drive-backed apps ~18s BEFORE the agent re-binds the drive; the create-time
volume bind fails (mkdir /mnt/felhom-drives/<drive>/userdata: permission denied)
and RestartCount=0 means it's never retried -> stuck Exited. The existing
recovery (processGuestBootChange) RAN but raced the rebind: it sampled the
agent's BoundUnderParent once during fast startup (not live yet), recreated
nothing, and persisted the new boot-id -> burned its one-shot. The periodic gate
never recovered them either (first observation after the rebind -> no transition).
Fix (harden the existing mechanism, no parallel one): processGuestBootChange now
gates on the REAL live in-guest bind. driveBindLive checks whether
/mnt/felhom-drives/<drive> is an actual mountpoint in the controller's own /mnt
(rslave) /proc/self/mountinfo -- true only once the agent's bind propagated,
exactly when docker can recreate the app. pollLiveBinds waits for that (bounded
~120s, poll 2s; rebind lands ~18s) and only then recreates via the normal
pipeline, including stuck-Exited create-time-failure apps (shouldRecreateOnBoot
is state-independent). Single-flight; absent-after-window drives left to the gate;
host-reboot path unaffected; guest-only reboot path now covered.
Tests: pollLiveBinds waits through the rebind then reports live (recreate fires);
never-live drive stays absent; pre-fix companion (single early sample misses the
not-yet-live bind). Red-proofed against a no-wait single-sample.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Vezérlő újraindítása card was accidentally placed inside the
{{if .RetrievalPassword}} block, so it was hidden whenever no retrieval
password is set (e.g. the demo). Moved it outside so the self-serve restart
is always available. Found during live verification of v0.70.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause (diagnosed live, not the hypothesised JS throw): filterCountries runs
fine and correctly populates the list, but reveals it with `list.style.display=''`.
The .geo-country-list CSS default is `display:none` (style.css), so clearing the
inline style falls back to none and the populated list never shows — no console
error, just an invisible dropdown. Latent since the geo feature's first commit.
Fix: reveal with 'block'. Verified live (typing "Német" now lists Németország).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Geo settings save and manual geo sync now fire an out-of-band, non-blocking hub
report push (Router.reportPushNow seam, wired in main.go to BuildReport+Push in a
goroutine) so the hub reflects the new geo state / clears a stale last_sync_error
within seconds instead of after the next ~15-min cycle. Scope: geo handlers only.
- builder.go always populates report.GeoRestriction (Enabled=false, empty countries
when nil/disabled) via new buildGeoRestrictionReport helper, so the hub always
renders the geo section ("Inaktív" when off) instead of hiding it via omitempty.
- Tests: geo save success → push once; invalid country → no push (companion);
buildGeoRestrictionReport(nil) → non-nil disabled (companion vs old nil-omit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/config/apply now takes effect via a graceful SELF-RESTART instead of
logging "restart needed" and leaving stale in-process singletons (the CF client
is built once at startup, so a rotated Cloudflare token never applied until a
manual LXC restart). Container is restart:unless-stopped, so a clean os.Exit(0)
auto-restarts with fresh config.
- New gracefulSelfRestart helper behind an injectable Restarter seam (Router.restart
+ SetRestarter) so the exit is unit-testable.
- configApply: no-op guard (byte-identical re-push → no write, no restart), else
write → 200 (flushed) → restart. Removed stale "restart needed" wording.
- Removed the dead OnConfigApplied hook (Phase-1-retired infra-backup push; the
self-restart reloads everything and a fresh report is pushed on startup).
- New POST /api/selfrestart (auth+CSRF via /api/ mount) + "Vezérlő újraindítása"
settings button: confirm → POST → poll GET / every 2s → reload.
- Tests: changed→restart once; identical→not called (companion); invalid→not called;
selfrestart→restart once.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Controller half of Phase-1 Infra Backup retirement (hub v0.12.0;
SPIKE-infra-backup-2026-06-15). Pure dead-code removal, no behaviour change.
- Remove Pusher.PushInfraBackup (caller-less; hub endpoint gone).
- Remove Notifier.NotifyBackupCompleted / backup_completed event (caller-less
since slice 8C; hub deadline check now reads agent host-report PBS snapshots).
- Remove report.BackupReport.ResticPassword — builder never sets it post-8C
(confirmed in source + live), but it historically leaked the restic password
into the hub's plaintext reports store.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The migration journal keeps returning the last completed job; the resume-view
watched any job and migWatch's done-branch reloads the page -> endless reload
loop after any migration. Resume-view now watches only in-progress jobs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
.stack-title-row gains flex:1+min-width:0 so it shrinks/wraps; .stack-state-badge
gains flex-shrink:0 so the nowrap badge is never compressed. Only unhealthy cards
clipped (the route-unpublished warning inflated the title-row).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
E1 caught it: filtering on State!=stopped missed apps docker hadn't auto-restarted
yet at the one-shot instant (5 apps exited after host reboot). Now recreates every
deployed present drive-backed app regardless of state (deployed=should run).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
recreateBootStaleApps ran before the stack manager finished scanning (GetStacks
empty) so it found no apps; add a bounded wait for stacks before the one-time
boot-stale recreate. Deterministic guest-reboot convergence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
recreateBootStaleApps recreates a present drive-backed app when boot-stale OR
exited/restarting/unhealthy (the recency-only gate missed already-exited apps).
Still skips healthy long-running + cleanly user-stopped apps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes guest-reboot convergence. driveGateLoop runs recreateBootStaleApps once
at startup: deployed drive-backed apps whose drive is present (BoundUnderParent)
and whose containers started recently (fresh guest boot, not a controller-only
restart) are recreated (down+up) onto the re-propagated drive. Paired with agent
v0.35.0's drive re-propagation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The drive-absent gate treats a stable path usable only when bound under the parent
(BoundUnderParent), not merely host-mounted. Makes a host reboot converge: apps
stay gated until the agent binds the drive under the parent, then are restarted
(recreated) on the populated path. Test updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix caught live: planDriveGates falsely marked the internal SSD path
/mnt/sys_drive/felhom-data disconnected (agent never reports it), which would
block starting SSD-resident apps. Gate now skips non-/mnt/felhom-drives/ paths.
Regression case added. No apps were stopped (none depended on the SSD path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drives are visible in-guest only at the STABLE /mnt/felhom-drives/<name>; the
registered path + HDD_PATH + FileBrowser source repoint there while agent calls
map back to raw /mnt/<name> (agentWhere). Enroll binds-under-parent before
register. Drive-absent GATE (planDriveGates + 30s driveGateLoop) stops/blocks
apps when a drive vanishes and auto-restarts on return; start-gate refuses start
when the drive is absent. H1 endpoints (disconnect/reconnect/restart-apps) routed
onto host-side ops. Non-hollow tests + companions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gtstef/filebrowser is a single Go binary that ignores a UMASK env (verified live:
-e UMASK=002 leaves PID1 0022), so RenderFileBrowserCompose wraps the entrypoint
sh -c 'umask 002; exec /home/filebrowser/filebrowser'. Customer-created folders now
come out 2775 (group-writable) so group-1000 apps can write into them. Test asserts
the wrapper is rendered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DeployStack's initial compose-up builds env from deploy values (not stackEnv), so
v0.66.0 missed USERDATA_PATH on first deploy → ${USERDATA_PATH} resolved to '' and
Docker bound a root-owned dir at the container root (found live: radarr /media/movies
was 0:0 755). Shared withUserdataPath injector now used by stackEnv AND
composeExecWithEnv. Regression test included.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agentapi.Decommission + handleStorageDecommission (migrate-all-or-none, Change 2):
migrate-then-decommission via the migration done-hook, or decommission-anyway (stop
apps, keep HDD_PATH). 'Hiányzó tárhely' badge on dashboard/stacks/app card when an
app's drive is decommissioned/disconnected/absent. Change 4: registerStoragePath
clears the decommissioned marker on re-enroll (ClearDecommissioned had no callers).
Non-hollow tests incl. mutation-proven Change-4 companion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ServeStorageAPI gains POST /api/storage/migrate (whole-namespace), POST
/api/storage/migrate-app (single app), GET /api/storage/migrate/status (poll).
settings.html: the greyed migrate-all span becomes a real target-select + button +
shared progress panel; app_info.html gains a per-app 'Áthelyezés másik tárhelyre'
control. Both poll the shared status endpoint and render Hungarian phase progress.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
internal/stacks/migrate.go: crash-safe, resumable namespace migration over the
controller's /mnt RW mount. Two entry points (whole-namespace + per-app) share one
journaled pipeline: validate -> stop -> copy (rsync -a --checksum, additive; conflict-
merge walk for non-app content) -> verify -> flip+redeploy (RedeployFromEnv) -> cleanup.
CLEANUP (the only destructive step) is gated on all units verified AND all apps
redeployed. Single-flight; mutual exclusion with the backup orchestrator (Change 3).
Non-hollow tests incl. mutation-proven collision + cleanup-gate companions.
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>
A1: AutoDiscoverStoragePaths no longer bails on a non-empty registry;
registers only deployed-app paths missing from the registry. Never
mutates/removes existing entries, never re-adds or reactivates a path
present in ANY state (incl. Decommissioned), never flips IsDefault.
A2: InferStorageLabel maps base==felhom-data namespace dir to
'Belső SSD (rendszer)' to disambiguate the internal system volume.
Table-driven tests incl. a companion that fails without the
skip-by-presence guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent (v0.31.0) now returns these on /disks, but the controller dropped them when
re-marshalling into its agentapi.DiskInfo mirror. Added both fields (additive) so they reach
/api/disks + the dashboard: wipe_durable_id (gate scheme, for the wipe-confirm) and guest_attached
(drive bound into THIS guest vs merely host-present). Controller behaviour otherwise unchanged.
ListDumpFiles ran ValidateDump (line-by-line scan) for every dump on every ~5-min
RefreshCache cycle — wasted I/O+CPU on large customer dumps. ListDumpFiles now takes
an optional cached(name,size,mod) lookup; on a (size+modtime) match it reuses the
prior result and skips ValidateDump. settings.DBValidationCache gains Size+ModTime;
listAllDumpFiles builds the lookup from the persisted cache and writes back only fresh
validations (cache miss), so an unchanged dump triggers neither a re-validation nor a
settings.json write each cycle. nil cached = legacy validate-always (back-compat).
Tests: cache-hit skips validate (sentinel), cache-miss validates, nil validates.
deriveStackName pure-suffix-stripped on '-' (postgres/db/mariadb/.../cache), so a
stack whose slug ENDS in a role token (e.g. 'my-cache') was misattributed (stripped
to 'my') — filing its DB dump under the wrong/nonexistent stack. Now threads the set
of deployed stack names (m.knownStackNames() <- ListDeployedStacks) into
DiscoverDatabases and cross-references: candidate suffix-strip if known, else the
container name if it IS a known stack, else longest known stack that is a prefix
(handles <stack>_postgres / <stack>-1), else legacy strip. nil/empty known = legacy
behaviour (appexport passes nil). Table test incl. the my-cache case (fails pre-fix).
All shippable work → main directly; report-only artifacts → felhom.eu/documentation/
(audits/backlog); risky/supervised fixes implemented on main during the supervised
session, not prepared on a branch; unattended escape hatch = revert+report, never park
on a branch. Supersedes the old 'prepared on fix/... branch, pending review' pattern.
The live-drive findings + fixspec now live in felhom.eu/documentation/audits/
(with the other audit records); they no longer belong loose at the controller
repo root. Transient SESSION-*.md working logs dropped (their lasting content is
in CHANGELOG/CONTEXT and the audit records). Trunk-based no-branches cleanup.