Files
felhom-controller/REUSE.md
T
admin 82c67e32e1 v0.168.0: customer-configurable backup window (Mentési időablak)
ONE setting (window start W) drives every nightly leg at fixed, never-stored
offsets: DB dump at W, tier-2 at W+60m, off-box at W+105m (wrap-safe). Precedence
settings > controller.yaml db_dump_schedule > 02:30.

- scheduler.UpdateDaily: retime a daily job at runtime (no restart) via a per-job
  buffered resched chan + a select case in runDailyJob.
- new pure package internal/backupwindow (LegTimes/GateWindow/EffectiveWindow).
- quiesce disk-tier window gate: scheduled cycles run only inside [W+2h,W+6h) with a
  safety valve (age>cadence+24h runs regardless); manual TriggerNow never gated.
  Backend.Due now also returns the backup age (from the agent's own /backup/due).
- backup page: Mentési időablak card (time input + derived leg/gate rows); POST
  /backups/window validates -> saves -> UpdateDaily x3 -> flash.

Tests: 5 groups, all red-proofed. Agent/cadence//backup/due untouched.
2026-07-24 20:55:44 +02:00

62 KiB
Raw Blame History

REUSE.md — felhom-controller

Before writing new code, check here. Canonical helpers, patterns to copy, traps to avoid. Maintenance: update in the SAME commit that adds/changes/deprecates a shared helper. Entries cite file + symbol. Line numbers are landmarks only — reconfirm before editing.

1. Canonical helpers (MUST reuse — do not reinvent)

Paths & namespaces (felhom-data layout)

Symbol File Short signature Use for Gotchas
NamespaceRoot controller/internal/appbackup/paths.go (drivePath string, inGuestDrive bool) string Resolve felhom-data root for a drive inGuestDrive=true returns path AS-IS (Model A: guest mount IS the ns root); false appends felhom-data. Never double-nest
PrimaryBackupPath / RecoveryUnitPath / RecoveryUnitComposePath / RecoveryUnitManifestPath controller/internal/appbackup/paths.go (nsRoot[, stackName]) string All backup dir layout Take the NAMESPACE ROOT, not a bare drive path
AppDBDumpPath / AppVolumeDumpPath / AppDataDir controller/internal/appbackup/paths.go (nsRoot, stackName) string Per-app dump/data dirs Same nsRoot contract. AppDataDir's final segment is the app's real appdata dir NAME — NOT always the stack name (paperless-ngx → paperless); resolve via AppDataDirNames first (F-S2/F-S3)
AppDataDirNames / AppDataBindsPresent controller/internal/appbackup/paths.go (hddPath, stackName string, hddMounts []string) []string / (hddPath, hddMounts) bool Resolve the real appdata/<name> dir(s) from compose ${HDD_PATH} binds (F-S2/F-S3) hddMounts = ParseComposeHDDMounts shape. Deduped+sorted; falls back to [stackName] when no appdata bind. Tier-2 (backup.Manager.tier2AppDataName) refuses N>1; migrate (stacks.Manager.ResolveAppDataDirNames) loops N. BindsPresent drives the WARN-on-missing-declared-dir
UserdataDir / EnsureUserdataSkeleton / EnsureDirOwned controller/internal/appbackup/userdata.go (nsRoot) / (path, gid int) userdata/ tree w/ 2775 setgid gid-1000 convention Linux-only chown via build-tag twin userdata_linux.go
HumanizeBytes controller/internal/appbackup/appdata.go (b int64) string Human byte sizes Exported canonical; private clones exist (§6)
stablePathForName / agentWhere controller/internal/web/intermediary.go (name/registeredPath) string Map registry stable path /mnt/felhom-drives/<n> ↔ raw agent mount Registry stores STABLE path; agent ops take the RAW mount — always convert
offsiteRestoreRootFor controller/internal/backup/offbox_verify_copies.go (drivePath string) string THE only place backups/offsite-restore is spelled offboxRestoreScratchDir builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0)
ProtectedHDDPaths controller/internal/stacks/delete.go (hddPath string) map[string]bool Never-delete set (root, appdata, backups, media, legacy felhom-data) Consult before ANY recursive delete under a drive

Subprocess + timeout + exit-code discipline

Symbol File Short signature Use for Gotchas
Manager.composeExec / composeExecCustomEnv controller/internal/stacks/manager.go (dir string, [env,] args...) (string, error) ALL docker-compose invocations Logs env KEYS only (secrets safety), truncates output to 500, extracts exit code; up triggers userdata pre-create belt. NO timeout — see §3
rsyncCopy controller/internal/stacks/migrate.go (ctx, src, dst, onBytes) Additive copy (migration/moves) -a --checksum, NEVER --delete; progress2 byte callback; ctx timeout
rsyncVerify controller/internal/stacks/migrate.go (ctx, src, dst) error Post-copy verification Dry-run -ani; fails on any pending content transfer; attr-only lines ignored
walkMerge controller/internal/stacks/migrate.go (lg, srcNS, dstNS, skip, assertOnly, onBytes) Collision-safe userdata merge Renames to lowest-free sibling on content mismatch; additive
runCommand / runCommandStdin controller/internal/selfupdate/updater.go (name, args...) (string, error) docker CLI in updater stdin variant for docker login --password-stdin (no secret in argv); package VARS since v0.112.0 — override in tests (fakeRunner in registry_anon_test.go)
parseWWWAuthenticate + fetchAnonymousToken controller/internal/selfupdate/updater.go Bearer-challenge parse + anonymous Docker v2 token Any credential-free registry API access realm comes FROM THE HEADER (never hardcode a token URL); denial = errAnonymousDenied, never "credentials missing"
Syncer.runGit / runGitInDir controller/internal/sync/sync.go (args...) error git CLI ops Credentials masked in logs via maskRepoURL

HTTP/JSON envelopes + flash messages

Symbol File Short signature Use for Gotchas
writeJSON controller/internal/api/router.go (w, status, v) REST API (/api/* router) responses Pair with apiResponse{OK,Data,Error} envelope
writeDiskJSON controller/internal/web/agent_disk_handlers.go (w, status, ok, errMsg, data) Storage/disk web-API responses The {ok,error,data} envelope the storage JS expects
jsonResponse / jsonError controller/internal/web/handler_export.go (w, v) / (w, msg, code) Export/import API Third envelope shape — keep within export surface
limitBody controller/internal/api/router.go (w, req) Bound request bodies (1MB) Apply before decode on any new POST
offboxRedirect controller/internal/web/offbox_handlers.go (w, r, msg string, isErr bool) Flash-message redirects Flash = ?flash= / ?flash_error= query params, read by page handlers
offboxRedirectTo controller/internal/web/offbox_handlers.go (w, r, page, msg string, isErr bool) Same, to an EXPLICIT page TRAP (fixed v0.154.0): the separator is chosen, not "?". Targets may already carry a query — the R-48 wizard is /backups/restore/app?name=<app> — and a hardcoded "?" buries the flash inside the previous parameter's value
restoreOpInFlight + hasRecentRestoreResult controller/internal/web/restore_wizard.go (backup.RestoreOpStatus) bool / (st, app, now) bool THE "is a restore running / did one just finish" display reads TRAP (v0.154.0 shipped this bug): Manager has TWO running flags. IsRunning() reads the CONCURRENCY flag, acquired inside the goroutine — and RestoreOffboxScratch never acquires it, so it is false for the whole verification restore. Display must read RestoreStatus().Running (set synchronously by BeginRestoreOp). Read the status ONCE per render or the strip and the suppression can disagree. hasRecentRestoreResult is app-bound and window-bounded — a process-wide result must not light another app's „Eredmény"
restoreWizardPath / deriveWizardStep / resolveWizardApp controller/internal/web/restore_wizard.go (app) string / (restoreWizardInput) restoreWizardView / ([]OffboxAppRow, name) *OffboxAppRow R-48 offsite restore wizard: URL builder + the PURE step/unlock derivation + the app-resolution refusals The step is never taken from the request. Precedence is load-bearing: op-running outranks a stale ?full_prep=, else a commit button reappears mid-restore. Truth table + red-proof: restore_wizard_test.go. Adding a form here that posts anywhere new breaks TestRestoreWizard_NoNewMutationEndpoints by design — R-48 adds no mutation surface
redirectTier2 controller/internal/web/tier2_config_handler.go (w, r, name, flash, flashErr) Tier2 page flash redirects Same convention
validStackName controller/internal/web/validate.go (name string) bool Any stack name from a request Single-segment, no / \ .. — blocks path traversal into stacks/userdata
ValidateSegment controller/internal/appexport/validate.go (kind, s string) error Any attacker-controlled path segment (.fab manifest fields) CTRL-001 guard; deliberately NOT for dotfile ConfigFiles
validateSubdomain / SubdomainInUse controller/internal/stacks/deploy.go (s) / (subdomain, excludeStack) Subdomain fields on deploy

Crash-safe journal / atomic writes

Symbol File Short signature Use for Gotchas
atomicWrite controller/internal/backup/recovery_unit.go (path, data, perm) error Atomic file writes (backup pkg) tmp+rename; no dir creation, no fallback
writeFileAtomic controller/internal/bootstrap/bootstrap.go (path, b) error controller.yaml writes from bootstrap Always 0600 (holds local-api token + hub key)
writeConfig0600 controller/internal/api/router.go (path, body) error config writes via API ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!)
atomicWriteFile controller/internal/setup/handlers.go (path, data, perm) error setup-wizard writes Same bind-mount fallback caveat
Settings.save (unexported) controller/internal/settings/settings.go via mutator methods only ALL settings.json persistence tmp+rename, then .bak last-known-good AFTER rename succeeds. Never write settings.json by hand
settings.Load controller/internal/settings/settings.go (path, logger) (*Settings, error) Startup load Corruption recovery: .bak restore → else preserve .corrupt-<ts> + safe defaults; never crash-loops
Manager.writeJournal / loadJournal controller/internal/stacks/migrate.go (j *MigrationJob) Migration crash journal Enables RecoverMigration at startup
backup.SharesPseudoStack / DisplayStackName controller/internal/backup/shares_payload.go "_shares" / (key) string THE reserved key for the shares source (restic tag, backups/secondary/_shares, CrossDriveBackup record) + its display mapping NEVER let the raw key reach a Hungarian surface — map at the notification/prose boundary ONLY; the persisted EnlargedBlocked set and the templates index by the RAW key
Manager.buildSharesPayload / classifiedShares controller/internal/backup/shares_payload.go () (dir, passdbOK, error) / () []classifiedShare the definitions+credential payload and the availability-filtered share set both tiers read payload is SECRET-BEARING (0600 passdb.tar) — never log its bytes/name at INFO. classifiedShares is the single place a dead mount is dropped, so both jobs agree
Manager.selectTier2TargetFrom controller/internal/backup/tier2.go (stack, sourceDrive, fullSize, stateOnlySize) (*Tier2Target, error) tier-2 target choice with the source drive supplied EXPLICITLY the seam the shares job reuses — NEVER fork the headroom math; selectTier2Target is now a thin wrapper over it
Manager.tier2ReconcileRoots controller/internal/backup/tier2.go (destBase, roots, legRels) staleness pruning with explicit dest roots pure extraction from tier2Reconcile (which now calls it with hdd/userdata); reuse it rather than writing a second pruner
Manager.liveShareRootOK / scratchJoin controller/internal/backup/shares_restore.go (dst) bool / (scratch, abs) string THE place guard for shares restore + scratch path reconstruction a snapshot is UNTRUSTED layout input: require a STRICT descendant of a live registered root, refuse .. and the drive root itself. scratchJoin strips the volume name — plain filepath.Join splices a drive letter mid-path
infra.SambaContainerName / SambaPassdbVolume / SambaPassdbMount controller/internal/infra/samba.go consts single source of truth for the samba container identity the compose renderer interpolates them; stacks/backup/monitor read them. The CONTAINER name (felhom-samba) is NOT the stack name (samba) — EffectiveProtected needs the container one
sambaWriteAtomic controller/internal/stacks/samba.go (path, data, mode) error samba smb.conf/compose writes tmp+fsync+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6
Loop.writeMarker / Recover controller/internal/quiesce/quiesce.go (m Marker) / () Quiesce crash-safety Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot

Compose ops / stack lifecycle

Symbol File Short signature Use for Gotchas
Manager.DeployStack controller/internal/stacks/deploy.go (req DeployRequest) (string, error) Full deploy flow Sets in-memory Deployed BEFORE compose up (slow-pull race), reverts on failure
Manager.RedeployFromEnv controller/internal/stacks/deploy.go (name, env map[string]string) error Re-up with changed env (migration flip, config edits) compose up -d, never restart (restart won't pick up images/env)
Manager.PersistUnitRedeployConfig (R-47, v0.153.0) controller/internal/stacks/deploy.go (name, env map[string]string) error the PERSIST half of RedeployFromEnv — app.yaml + locked fields + in-memory flags, starts nothing TRAP: the restore paths must use THIS, never RedeployFromEnv. RedeployFromEnv ends in a full up -d, which before the replay IS the H4 race. RedeployFromEnv is now literally this + the unchanged up-and-report tail
Manager.StartStackServices (R-47, v0.153.0) controller/internal/stacks/manager.go (name string, services []string) error scoped compose up -d <svc>... — the DB-only window a dump is replayed in REFUSES an empty list (argument-less up -d is a FULL start — the one silent fall-through that would reintroduce the race). No logPostStartStatus: the app containers are absent on purpose. Never RestartStack here — it is a full up in disguise
appbackup.DBServiceNames / dbTypeForImage (R-47, v0.153.0) controller/internal/appbackup/dbservices.go (composePath string) ([]string, error) naming the compose SERVICE(s) holding a database, sorted yaml.v3 services: MAP parse — never a line scan (immich's top-level immich_ml_cache: / immich_postgres_data: volume keys look exactly like services). dbTypeForImage is shared with DiscoverDatabases, which is what makes "a dump exists ⇒ a service can be named" hold. An error means CANNOT-TELL, never "no database" — callers refuse when a dump exists
Manager.StartStack/StopStack/RestartStack/UpdateStack controller/internal/stacks/manager.go (name string) error Lifecycle Protected stacks refuse stop; all funnel through composeExec
Manager.DeleteStack / RemoveStack controller/internal/stacks/delete.go (name, removeHDDData[, backupPaths]) THE guarded removal paths Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll
resolveContainerState / aggregateState controller/internal/stacks/manager.go (dockerState, dockerStatus) / ([]ContainerInfo) State classification .State says "running" even when unhealthy — .Status parse is the fix
Manager.logPostStartStatus controller/internal/stacks/manager.go (name, stackDir, env) Async post-start verification compose up exits 0 on crash-loops; this is the detection. Goroutine + 3s, never blocks
Manager.EnsureBaseStack controller/internal/stacks/infra.go () error Traefik/cloudflared/FileBrowser infra convergence Renders from internal/infra templates
appbackup.ClassifyBinds / ValidateBackupSpec controller/internal/appbackup/classify.go (spec, binds) ([]ClassifiedBind, bool) / (spec, binds) error Backup-classification (Task 2, referential coupling) — pure Two-level default: explicit wins over :ro; unlisted writable→mandatory, unlisted :ro→excluded; nil spec→legacy/false. Validate REJECTS the WHOLE block on any defect (whole-block semantics). INERT — no tier consumes it yet
ParseComposeClassifiableBinds controller/internal/stacks/classify_binds.go (composePath) []appbackup.ComposeBind ${VAR}-relative binds + :ro for classification Do NOT use ParseComposeHDDMounts/ExportDataMounts as classifier input (§traps) — they resolve absolutes, drop :ro, or union the userdata ROOT. Short-syntax only
Metadata.EffectiveLifecycle / CanInstall / IsAbandoned + web.lifecycleBadge / web.visibleCatalogStacks controller/internal/stacks/metadata.go, controller/internal/web/metabadge.go, controller/internal/web/handlers.go meta.CanInstall() bool app lifecycle: available / hidden / abandoned (v0.158.0) THE single interpretation of .felhom.yml lifecycle: — every surface must go through these, never compare the raw string. Listing drops !Deployed && !Protected && !CanInstall(); api.deployStack refuses server-side BEFORE any mutation (hiding a button is not a gate), stacks.DeployStack repeats it for non-API callers. Unknown value fails OPEN (→ available + one WARN) — opposite to the gate on purpose: a typo must never pull a working app out of every catalog. NEVER let lifecycle reach orphan detection (getCatalogTemplateSlugs) — a withdrawn template stays in the tree, or every deployed instance reads as Elavult and gets a Törlés button. Badges: MetaBadge + meta_badge partial, built generic for R-56 difficulty labels
Manager.ClassifiedBinds + StackDataProvider.GetStackClassifiedBinds controller/internal/stacks/metadata.go, appbackup/appdata.go (name) ([]appbackup.ClassifiedBind, bool) Per-stack classification through the REAL LoadMetadata validate path The wired seam Task 3 consumes; LoadMetadata is the SINGLE validation choke point (bad block → nil + one ERROR → legacy)
backup.Manager.DumpAppVolumesSafe controller/internal/backup/backup.go (stackName) error Volume tar of a live app Stops → dumps → restarts; surfaces BOTH errors (app may be left stopped). Check GetDockerVolumes()!=0 + IsProtectedStack BEFORE calling — it stops the stack before its own volume check (see runVolumeDumps)
backup.Manager.ListRestorePoints controller/internal/backup/restore_points.go (stackName) ([]RestorePoint, bool) Restorable keep-side backups (the /api/backup/snapshots payload) ONE point per app (the current unit); tier always 1 — never list Tier-2 (not restorable via /backup/restore)
backup.Manager.RestoreTier2Files controller/internal/backup/tier2_restore.go (stackName) (filesRestored int, err error) In-place ADDITIVE-ONLY class-C file restore from the recorded Tier-2 copy (POST /backup/tier2/restore) Never overwrites/deletes live files; refusals (Hungarian) before any stop; source = recorded DestinationPath, never re-selected
Manager.acquireRunning/releaseRunning, acquireMigrating controller/internal/backup/backup.go, controller/internal/stacks/migrate.go () error Single-flight for long ops Copy this mutex-flag pattern for any new long-running manager op

Secrets hygiene

Symbol File Short signature Use for Gotchas
crypto.Encrypt/Decrypt/IsEncrypted/DecryptMap controller/internal/crypto/crypto.go AES-256-GCM, ENC: prefix app.yaml sensitive values Decrypt errors on non-ENC input — use DecryptMap for whole env maps (passes through + warns)
crypto.LoadOrCreateKey controller/internal/crypto/crypto.go (path) ([]byte, error) The 32-byte key file (0600)
SaveAppConfig / LoadAppConfigDecrypted controller/internal/stacks/deploy.go (stackDir, cfg, encKey, sensitiveVars) app.yaml persistence Encrypts only SensitiveEnvVars(meta); never write app.yaml directly
generateValue / randomAlphanumeric controller/internal/stacks/deploy.go (spec "password:N|hex:N|base64key:N|static:v") Auto-generated secrets crypto/rand-backed; reuse the spec grammar
Manager.GenerateSecretForField controller/internal/stacks/deploy.go (stackName, envVar) (string, bool) Replacement value for a RESETTABLE secret from its catalog generate spec (O4 restore path via backup.SetSecretGenerator) REFUSES data_key fields, spec-less and non-secret fields; never log the value
reconcileRestoreSecrets controller/internal/backup/restore_unit.go (nonSecretEnv, recoveredSecrets, secretNames, dataKeyNames) Recovery-unit restore env merge Units are secret-FREE by design; secrets come from live app.yaml
EncryptFile / DecryptFile / IsEncryptedFAB controller/internal/appexport/crypto.go password-based file crypto .fab export bundles scrypt-derived AES+HMAC keys
maskRepoURL controller/internal/sync/sync.go (url) string Logging git URLs Strips embedded credentials
metrics.RedactLine controller/internal/metrics/redact.go (s string) string ANY log line shipped off-box (issue context, log tails) Masks password/passwd/secret/token/api-key/authorization/bearer values + 64-hex; apply BEFORE the line leaves the box — controller-side redaction is authoritative

Storage registry + mount detection

Symbol File Short signature Use for Gotchas
Settings.AddStoragePath/RemoveStoragePath/RepointStoragePath controller/internal/settings/settings.go registry CRUD ALL drive registration AddStoragePath dedupes (double-register is clean no-op); AutoDiscoverStoragePaths never re-adds a known-in-any-state path
Settings.SetDisconnected/ClearDisconnected/SetDecommissioned controller/internal/settings/settings.go state flags + stopped-stacks memo Drive lifecycle state Records stoppedStacks so reconnect restarts exactly those
registerStoragePath controller/internal/web/storage_handlers.go (where, label, setDefault) error Post-enroll registration The single funnel used by init/attach/manual-add
system.IsMountPoint / IsWritable / PathsOverlap controller/internal/system/mounts_linux.go (path) bool Mount checks _other.go stubs return permissive values — Linux behavior is the real one
system.CheckBackupDestination controller/internal/system/mounts_linux.go (path) DestinationHealth Tier2/offbox target vetting Detects same-physical-device (SamePhysicalDevice)
system.ProbeStoragePath controller/internal/system/mounts_linux.go (path) ProbeResult Disconnect detection
appexport.DiskFree controller/internal/appexport/estimate.go (path) int64 Free bytes for space gates (df-based, 0 on any error) Exported v0.128.0 for the browser-upload gate; test seam = web.uploadDiskFree package var
stacks.ExportDataMounts controller/internal/stacks/delete.go (composePath, hddPath) []string THE .fab-export mount discovery (v0.130.0 C6B-F1) Unions ${HDD_PATH} binds + the ${USERDATA_PATH} ROOT (single userdata entry — basename must round-trip the import's <HDD_PATH>/<subdir> mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side stackAdapter deliberately does NOT use it
Server.deployedAppsOnPath controller/internal/web/netstorage_handlers.go (base) []string Deployed stacks whose HDD_PATH is base or a subpath The C6B-F2 share-removal guard; nil-safe on stackMgr
planDriveGates / Server.ReconcileDriveGates controller/internal/web/intermediary.go pure plan + executor Drive appear/disappear reactions planDriveGates is PURE (unit-testable); loop at driveGateLoop
Server.runStorageInit / runStorageAttach controller/internal/web/storage_handlers.go wizard pipelines New-drive enroll / re-attach Format goes through the agent's two-step confirm (below)

| Server.sharingResolvePath / sharingResolveStorageRoot | controller/internal/web/sharing_handlers.go | (raw) (string, error) | THE guard for every customer-supplied SMB share path | resolvePath validates a share TARGET (refuses the drive root); resolveStorageRoot validates the new-folder PARENT (accepts exactly a registered live root). Refusals are UNIFORM (no filesystem oracle). Never add a second deny-list — stacks.SharingDeniedRoots derives from ProtectedHDDPaths |

Agent local-API client (cross-repo edge)

Symbol File Short signature Use for Gotchas
agentapi.New controller/internal/agentapi/client.go (endpoint, token, fingerprintHex) (*Client, error) Pinned-TLS client to felhom-agent Leaf-DER SHA-256 pin replaces chain verify; fails closed. Bounded idle pool (leak fix)
Server.agentClient controller/internal/web/agent_disk_handlers.go () (*agentapi.Client, error) THE memoized client accessor Always use this, never a fresh agentapi.New per request (§3)
Server.ProbeAgentChannel controller/internal/web/agent_disk_handlers.go (ctx) (constructionErr bool, err error) Channel health probe Probes via the PRODUCTION client on purpose (self-heals, mirrors UI)
Client.FormatDisk controller/internal/agentapi/client.go (ctx, device, fstype, confirmed, durableID) ONLY format/wipe entry Sentinels: ErrNeedsConfirmation (user-data, resubmit confirmed+durableID) / ErrFormatRefused (system/backup — operator opsign only). Agent re-checks role server-side
Client.EjectDisk / Decommission / AssignDisk / GuestAttach / ListCandidates controller/internal/agentapi/client.go disk lifecycle Delegate ALL disk ops to agent Controller holds no Proxmox creds — never shell out to disk tools in-guest
Client.AddNetStorage/ListNetStorage/RemoveNetStorage controller/internal/agentapi/client.go NAS mounts (A1) Network storage Password passes through to agent's 0600 cred file; controller NEVER persists it
agentapi.StatusError controller/internal/agentapi/client.go {Path, Code} typed non-2xx GET error Distinguishing HTTP statuses from transport errors (errors.As) NEVER string-match agent error text — the capability probe keys on Code==404
SupportCache.Supports / Client.Supports controller/internal/agentapi/features.go (ctx, prober, Feature) SupportState Agent-capability gate for COUPLED features (route probe, TTL 5m) 404 ⇒ No; transport/5xx ⇒ Unknown (NEVER refuse on Unknown). New coupled feature = new featureProbes row + gate call at the entry point + MinAgent: in the CHANGELOG header (publish-train-rules.md). Web layer: Server.netFeatures through the netAgent seam

Notifications / hub sync

Symbol File Short signature Use for Gotchas
Notifier.PushEvent controller/internal/notify/notifier.go (eventType, severity, message, details) Hub events Async goroutine, 3 attempts/3s backoff. NEW event types MUST be added to hub allowedEventTypes or POST /event 400s; hub only emails warning/error from this path
Notifier.Notify* convenience methods controller/internal/notify/notifier.go typed wrappers (backup/DB/storage/channel/DR…) Standard events Add a typed wrapper rather than raw PushEvent calls
report.BuildReport / Pusher.Push controller/internal/report/builder.go + pusher.go periodic hub report Box→hub reporting ACK carries config_versionConfigRefresher.Reconcile
report.Trigger (NewTrigger/Fire/Run) controller/internal/report/trigger.go Fire() after a hub-relevant user action THE out-of-cycle report push (v0.139.0) — fire via api.Router.reportPushNow / web.Server.reportTriggerNow, both nil-safe Coalesce-and-eventually-fire (trailing edge; quiet 2s, min spacing 15s). NEVER add retries (Pusher owns them); NEVER reuse the internal/sync REFUSE-debounce for hub pushes (a refused fire loses the update until the next cycle). Fire only AFTER a successful local commit
report.SetPendingLogTails + buildLogTailsSection controller/internal/report/logtail.go ACK log_tail_requests → next report log_tails THE pull-based ACK-flag pattern (hub asks, controller pushes next cycle) — copy for any new hub→box request Consume-once drain at BuildReport; failed push re-arms from the hub's still-pending request; NEVER add a hub→controller push channel
metrics.FetchContainerLogTail controller/internal/metrics/logscanner.go (name, tailLines) (string, error) Raw per-container docker logs --tail=N 15s timeout; caller caps/redacts (capTailLines)
ConfigRefresher.Reconcile controller/internal/report/config_refresh.go (ackVersion int) Pull-based config refresh Re-pulls controller.yaml (re-merging local_api), then graceful self-restart; first-run = baseline, no restart
offsiteapply.SettleProvider / SettleFunc / Bridge.AwaitSettle / ReconcileWhenSettled (R-71a, v0.162.0) controller/internal/offsiteapply/offsiteapply.go + seams.go SettleState() (version, floor string, updateRunning, floorKnown bool) THE settle-gate: defers the offsite one-time-password consume past a managed day-0 floor-update (the F10 race). Wire the SettleFunc adapter over updater.GetFloor()/IsUpdateRunning()the updater's knowledge is the ONE floor source; never fetch the floor a second way. Gate ONLY the bridge goroutine, and only when an updater exists (nil Settle = reconcile immediately). Bounds settlePoll/settleFloorSubBound/settleOverallBound; the floor is in-memory (report-ACK-derived, ~510 s), NOT persisted → unknown until the first ACK on any restart. Inject Now/Sleep in tests (no real sleeps). B: at/above-floor GOes on the first poll, zero wait. Do NOT touch the consume/persist order or the 404 contract — ordering only
bootstrap.MaybeIngest / RefreshConfig controller/internal/bootstrap/bootstrap.go bootstrap.json → controller.yaml Day-0 + refresh Overwrites controller.yaml, NEVER settings.json
api.GracefulSelfRestart controller/internal/api/selfrestart.go (logger) Controller self-restart Detached exit; bootstrap unit re-runs the image
Settings.AddPendingEvent/DrainPendingEvents controller/internal/settings/settings.go offline event queue Events while hub unreachable

Scheduler / time / UI

Symbol File Short signature Use for Gotchas
Scheduler.Every / Daily controller/internal/scheduler/scheduler.go (name, interval/"HH:MM", fn) ALL background jobs Daily is Europe/Budapest, DST-safe (nextDailyRun avoids Add(24h)); register in main.go block (§5)
Scheduler.UpdateDaily controller/internal/scheduler/scheduler.go (name, "HH:MM") bool Retime a daily job at runtime (no restart) Per-job buffered resched chan + select case in runDailyJob; false (WARN) on invalid time / unknown-or-non-daily name; read Schedule under the mutex in the loop
backupwindow.* (LegTimes / GateWindow / EffectiveWindow / ParseHHMM / FmtHHMM / Valid) controller/internal/backupwindow/backupwindow.go pure stringint Backup-window arithmetic (v0.168.0) Offsets (W+60m/W+105m, gate W+2h..W+6h) are CONSTANTS — derived, never stored; wrap-safe modulo 1440; EffectiveWindow(settings, yaml) = settings>yaml>"02:30"
getBudapestLocation controller/internal/scheduler/scheduler.go () *time.Location Local-time math web has its own getTimezone (§6); quiesce has its own budapestLocation (window gate) — 3rd copy, see §6
Server.templateFuncMap controller/internal/web/funcmap.go template.FuncMap ALL template functions stateColor outputs v2 suffixes run/progress/warn/neutral/off; stopped = NEUTRAL not red (operator-approved); stateLabel copy is frozen byte-identical (unit-tested)
timeAgoStr controller/internal/web/funcmap.go (s RFC3339 string) string Ago-format for STRING timestamps Exists because timeAgo(time.Time) 500'd on strings (v0.93 bug)
Server.baseData / executeTemplate controller/internal/web/handlers.go + server.go page-data plumbing New pages baseData injects nav/alerts/version; templates must pass controller/scripts/template_id_gate.py + controller/scripts/emoji_gate.py
Server.RequireAuth / CsrfProtect / csrfField controller/internal/web/auth.go + csrf.go middleware Any new authed route/form csrfField emits the hidden input; setup wizard has its OWN csrf (§6)
LogBuffer + Lines(maxBytes) controller/internal/web/logbuffer.go ring buffer io.Writer In-memory log capture for the debug UI + the report controller_log_tail source v0.116.0: ALWAYS constructed (any logging.level) — the logger is MultiWriter(LevelFilterWriter(stdout, level), ring); Lines drops OLDEST to honor the byte budget
logx.Debugf/Infof/Warnf/Errorf controller/internal/logx/logx.go (l *log.Logger, format, args…) ALL NEW leveled log lines (the v0.116.0 sweep standard) routing is the WRITER's job — Debugf always reaches the ring, stdout filters; nil logger = no-op; caller-attributed (Output calldepth 3)
web.LevelFilterWriter controller/internal/web/levelfilter.go NewLevelFilterWriter(w, minLevel) stdout leveling under the always-on ring untagged lines parse INFO; always reports full length written
monitor.RunHealthCheck / EffectiveProtected controller/internal/monitor/healthcheck.go system health report Health + protected-container list
util.TruncateStr controller/internal/util/strings.go (s, maxLen) string Rune-safe truncation The intended shared helper; stacks still uses its byte-based twin (§6)

2. Canonical patterns (copy structure from THE named file)

Pattern Canonical file Key traits
Agent-proxy web handler controller/internal/web/agent_disk_handlers.go memoized s.agentClient() → typed client call → writeDiskJSON envelope, Hungarian error strings, 502/503 mapping
Two-step confirmed destructive op controller/internal/web/storage_handlers.go handleStorageWipe server-side type-to-confirm + probe(unconfirmed) → sentinel error → resubmit bound to agent durable-id; agent re-checks role regardless
Crash-safe long job (journal + recover) controller/internal/stacks/migrate.go state machine + writeJournal per transition + RecoverMigration at startup + single-flight acquire/release + done-hook
Quiesce/marker loop controller/internal/quiesce/quiesce.go marker BEFORE side effects, guaranteed undo (defer + max bound), Recover() once at startup, TriggerNow 409 single-flight
Settings mutator controller/internal/settings/settings.go (any Set*/Add*) Lock → mutate → s.save(); getters return copies; never expose internal slices
Channel-health checker w/ born-down alerting controller/internal/channelhealth/checker.go classify → debounce N≥2 → alerted flag re-armed on reason change (F2)
Platform split controller/internal/system/mounts_linux.go + mounts_other.go _linux.go/_other.go twins; other = permissive no-op stubs for dev on Windows
Debounced trigger + status (REFUSE-style — a too-soon fire is refused/lost) controller/internal/sync/sync.go TriggerSync 30s debounce, Status() snapshot struct, post-sync hook fan-out
Coalescing trigger (trailing edge — a burst collapses but the LAST state always fires) controller/internal/report/trigger.go buffered-1 chan + non-blocking Fire() + single worker (quiet window → drain → min-interval → fire once); shape from hub wgsync/reconciler.go
Detached job + status poll (single-flight, phase strings) controller/internal/web/storage_init_job.go acquire/release/set/deep-copied snapshot; phases mapped to Hungarian in the template; 13 s poll; terminal state PROBED, not inferred. Clones: netstorage_job.go, samba_ensure_job.go (v0.147.0). Five of these now exist and agree on nothing — R-45 will unify them; prefer extending an existing one over a sixth
Streaming subprocess progress controller/internal/backup/offbox_progress.go offboxStreamRunner seam (stdout scanned line-by-line, stderr buffered, output tail-bounded) + a PURE line parser + a mutex-guarded published snapshot. Traps it encodes: a source reporting nothing is normal (restic sends 0 bytes for a whole incremental run) and the progress source may only update on unit completion — degrade bytes → files → current item + elapsed, never fake a percentage
Post-start async verification controller/internal/stacks/manager.go logPostStartStatus goroutine + sleep, INFO log, never blocks/fails the operation
Startup wiring order controller/cmd/controller/main.go init-only setters (SetStackProvider M2 contract: exactly once, before scheduler/HTTP), scheduler registration block

3. Dangerous lookalikes — do NOT reuse

Trap Why it bites Use instead
rsyncMirror (controller/internal/backup/tier2.go) rsync -a --delete — DESTROYS anything extra at dst; correct only for tier-2 mirror dirs (backup DIRECTION). In the tier2→live restore direction it would erase every live file created since the last copy rsyncCopy + rsyncVerify (controller/internal/stacks/migrate.go) for any move/copy; rsyncRestoreMissing (controller/internal/backup/tier2_restore.go, -a --ignore-existing) for the additive-only restore direction
raw os.RemoveAll on drive/HDD paths Bypasses the protected-set; wipes appdata/backups/media Manager.DeleteStack/RemoveStack (controller/internal/stacks/delete.go) — gated by ProtectedHDDPaths + orphan/protected/running checks
fresh agentapi.New per request Idle-conn leak → EADDRNOTAVAIL, port exhaustion (live incident, fixed ctrl v0.74.0) Server.agentClient() memoized accessor
timeAgo on an RFC3339 string field Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) timeAgoStr
backup.Manager.DumpAppVolumes on a running DB app Inconsistent tar of live DB volume DumpAppVolumesSafe (stop → dump → restart, both errors surfaced)
stacks.Manager.execCommand / composeExecCustomEnv for NEW long-running calls No context/timeout — a hung docker CLI blocks forever exec.CommandContext + explicit timeout (copy rsyncCopy or appexport composeExecEnv)
config.LoadPermissive Skips validation — setup-mode only (customer.id/domain may be unset) config.Load everywhere else
ExportDataMounts / ParseComposeHDDMounts as backup-classification input ExportDataMounts unions the ${USERDATA_PATH} ROOT (export-capture logic, not per-bind); ParseComposeHDDMounts resolves absolutes AND drops the :ro flag — classification needs ${VAR}-relative paths + read-only awareness ParseComposeClassifiableBinds (controller/internal/stacks/classify_binds.go)
docker compose restart (any wrapper) Does not pick up new images or env RedeployFromEnv / composeExec up -d

4. Seams & interfaces (testing + cross-repo)

Interface Defined in Implemented by Fakes/tests at
diskAgent controller/internal/web/storage_handlers.go *agentapi.Client mockAgent in controller/internal/web/storage_handlers_test.go
netAgent + Server.netAgentFn/netProbeFn/netListFn controller/internal/web/netstorage_job.go (+ server.go fields) *agentapi.Client / runNetProbe (linux re-exec) / agent.ListNetStorage fakeNetAgent + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests
Server.agentLogsFn (func seam) controller/internal/web/server.go nil → agentClient().DebugLogs (agent GET /debug/logs) injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path)
escrowAgent + Server.escrowAgentFn/escrowStageFn/escrowStaleFn controller/internal/web/escrow_handlers.go (+ server.go fields) *agentapi.Client / PushOffboxPasswordForEscrow / report.EscrowAutoConfirmer.StaleBlob (SetEscrowStale) fakeEscrowAgent + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated
offboxCeremonyWaitState + escrowCeremonyGraceWindow controller/internal/web/handlers.go pure pick: (awaiting, timedOut) from OffboxTarget.{EscrowState,CeremonyCompletedAt} — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via >=)
Manager.sambaUpFn / sambaPasswdFn / sambaRunFn / sambaAddrFn (func seams) controller/internal/stacks/manager.go (fields) + samba.go nil → composeUp / docker exec smbpasswd (STDIN) / containerRunning("felhom-samba") / docker exec felhom-samba ip -4 -o addr show eth0 injected in controller/internal/stacks/samba_test.go — the idempotency test asserts the up-seam is called zero times when config is unchanged; the passwd seam means no unit test ever handles a real secret or touches docker. sambaRunFn has an EXPORTED setter (SetSambaRunProbe) — internal/web's status-contract tests need a live-container world from another package. sambaAddrFn backs SambaLANAddress() (v0.151.0); its parse is separately pinned in samba_lanaddr_test.go and it returns "" on any failure — the page omits a line rather than printing a wrong address
Manager.SambaLANAddress() controller/internal/stacks/samba.go () string — the guest's LAN IPv4 for the Megosztás connect card (v0.151.0, S-2) Read from the SAMBA container's netns (network_mode: host), never net.InterfaceAddrs() — the controller is on a docker BRIDGE and would answer 172.x (the same trap setup.DetectLocalIPs needs HOST_IP for). NEVER cache/persist it — the guest holds it by DHCP (S-5); callers re-derive per render. "" = omit the line
Server.sambaAddrFn (func seam) controller/internal/web/server.go (field) + sharing_handlers.go sambaLANAddress() nil → stackMgr.SambaLANAddress() The web-side half of the connect card. Tests inject a COUNTED fn — the fresh-per-render assertion is what stops anyone memoizing a DHCP lease
Manager.guestNetExecFn (func seam) + GuestGateway() / GuestNetSnapshot() controller/internal/stacks/manager.go (field) + guestnet.go nil → docker exec felhom-samba <args> — ONE seam for all R-66 guest-netns reads (route/link/addr/resolv.conf); tests script canned outputs per argv guestnet_test.go. The netns door rule: the controller's OWN netns is the docker bridge, so any in-process read (net.Interfaces, /proc/net/route, its own /etc/resolv.conf = 127.0.0.11) is the S-2 wrong answer — guest-net reads MUST go through the samba (network_mode: host) exec door. Megosztás off ⇒ door closed ⇒ "" / per-item error strings; NEVER substitute an in-process value. Same S-5 law as SambaLANAddress: live per render, never cached/persisted. Parsers (parseDefaultRoute, parseGuestInterfaces, parseResolvConf) are pure + separately pinned
buildFileBrowserPaths + fbPathDeps (R-67, v0.160.0) controller/internal/web/handlers.go pure assembly of one FileBrowser sync pass: (mount lines, config source paths) from the registry, with per-kind gates filebrowser_network_test.go. Two storage classes, two DIFFERENT gates: drives keep the drive-absent gate + userdata scoping + skeleton (byte-identical to pre-R-67 — tested); network shares bind the share ROOT :rslave with the STUB gate instead (classifyFSPath; stub ⇒ excluded from mounts AND sources — an exposed stub swallows uploads the real mount later shadows; idle autofs / unknown ⇒ include, fail open). NEVER call EnsureUserdataSkeleton toward a network path (red-proven); never force-wake an idle trigger in the sync (doctrine)
Server.guestGatewayFn / guestNetFn (func seams) controller/internal/web/server.go (fields) + sharing_handlers.go accessors nil → stackMgr.GuestGateway / stackMgr.GuestNetSnapshot network_card_test.go — the counted-fn freshness test (2 renders ⇒ 2 resolves) is what stops anyone memoizing a DHCP lease; the Hálózati név row is gated on smb.Enabled (red-proven: gate dropped ⇒ \FELHOM rendered while samba is down)
sambaEnsureState.consumeIfRunning() controller/internal/web/samba_ensure_job.go serve-once snapshot() for terminal running only /sharing/status carries a job EDGE (phase) and a service LEVEL (running) in one envelope — never let a level reach the phase channel, and never re-serve a consumed edge: the client answers phase=="running" with location.reload(), so both mistakes produce an infinite page reload (S-1/S-4, DIAG-sharing-2026-07-20.md). failed/needs_password/in-flight are NOT consumed
infra.SambaHostInterface controller/internal/infra/samba.go the guest LAN nic name (eth0) Single source for smb.conf's interfaces =, the container's FELHOM_IFACE, and the LAN-address read — if they name different nics, the service and the address the page prints drift apart
Manager.sambaImgFn (func seam) controller/internal/stacks/manager.go (field) + samba.go nil → docker image inspect <infra.SambaImage> drives the 4b card's pulling-vs-starting decision, which MUST be taken before compose up (afterwards the image is always present)
Manager.offboxStreamRunner + SetOffboxStreamRunner controller/internal/backup/offbox_progress.go nil → defaultOffboxStreamRunner (real restic, stdout scanned live) streaming sibling of offboxRunner; fakes emit canned --json status lines in offbox_progress_test.go, so the whole progress path runs with no restic, network or repo
Manager.offsitePreDumpFn + SetOffsitePreDumpFn (R-44, v0.148.0) controller/internal/backup/offbox_reconstitute.go (seam) + offbox.go (call site) nil → runDBDumpsInternal under the SAME running flag THE dumps-before-capture ordering seam. Extracted so the order is observable without Docker/restic — an ordering guarantee no test can see is one refactor from silently reverting to the DIAG-immich-restore-2026-07-19 behaviour. Red-proof: moving the capture first yields [capture dump]
Manager.offboxFullPlaceCopier + SetOffboxFullPlaceCopier (R-43) controller/internal/backup/offbox_reconstitute.go nil → rsyncRestoreOverwrite (-a --itemize-changes; no --ignore-existing, no --delete) TRAP: do NOT reuse offboxPlaceCopier here. The two copiers have OPPOSITE semantics for an existing file — --ignore-existing is exactly what a full restore must not do, and conflating them is how a missing-only merge came to be labelled a restore. Never rsyncMirror (--delete) in any restore direction
Manager.safetyDumpFn + SetSafetyDumpFn (R-43) controller/internal/backup/offbox_reconstitute.go nil → DumpOne the pre-restore undo. Invariant: the pre-restore--prefixed dump must be verified ON DISK before anything is stopped/overwritten/replayed; failure ⇒ refuse with zero changes. Red-proof requires removing BOTH guards (the err != nil return and the os.Stat) — removing one leaves the other holding
reimportDBDumpsFrom(ctx, stack, dumpDir) controller/internal/backup/restore_db.go explicit-dir sibling of reimportDBDumps (which passes AppDBDumpPath) offsite reconstitution replays from the SCRATCH unit: the live unit is deliberately never overwritten, so replaying from it would replay the current DB over itself and restore nothing
The DB-only replay window (R-47, v0.153.0) controller/internal/backup/{offbox_reconstitute,restore_unit}.go both restore paths: stop → place/volumes → StartStackServices(dbServices) → replay → StartStack (full) THE ordering invariant. Replaying while the whole stack is up lets the app's own schema management race the dump — measured at 2 s on 2026-07-19 (H4), replay aborted already exists. Fail-closed: a dump with NO identifiable DB service refuses BEFORE the first mutation. Every exit from the window (replay error, DB-only start error) MUST still do a best-effort full start, or a failed restore becomes an outage. hasReplayableDump excludes pre-restore- safety dumps — counting them would arm the window for an app with nothing to replay
Manager.OffsiteScratchPair / OffsitePairInfo controller/internal/backup/offbox_reconstitute.go reads the restored scratch unit's manifest (offsite_run_id / dumps_at) + the R-44 sniff the confirm-dialog honesty surface. All warn-level: a pre-v0.148 (unstamped) pair and an empty-looking dump are SURFACED, never blocked — a false positive that refused a legitimate restore would be worse than the skew
appbackup.DumpValidation.LooksEmpty (R-44 sniff) controller/internal/appbackup/dbdump.go computed in ValidateDump's existing single pass; userTableNames is EXACT-match size and table count are both useless as emptiness heuristics (the 2026-07-19 dump: 52MB, 60+ tables, zero users — all geodata). TRAP: never widen to a substring match on "user" — it would flag user_metadata / album_user / user_audit on every healthy single-user box. A row wider than the read buffer still counts as a row
Manager.execFn (func seam) + restartPolicyLookup / inspectRestartPolicyFn (R-51, v0.156.0) controller/internal/stacks/manager.go nil → real exec.Command / docker inspect -f {{.HostConfig.RestartPolicy.Name}} scriptedDocker in controller/internal/stacks/degraded_test.go drives the WHOLE production path (docker ps → aggregateState → docker inspect) — an aggregateState-only test proves the function, not the caller. Policy answers are cached per container+state and pruned to the live docker ps set; a FAILED inspect is deliberately never cached (a hiccup must not pin a container to "unknown") and reads as SUPERVISED, i.e. fail-closed — the opposite of IsDownState's fail-open, because there the state is ambiguous while here a member is known dead
bootrecon.StackProvider (R-52, v0.156.0) controller/internal/bootrecon/bootrecon.go *stacks.Manager (GetStacks/StartStack/RefreshStatus) fakeStacks counts StartStack per app; the load-bearing assertion is the NEGATIVE — a zero-container stack (a UI Stop = compose down = containers removed) must record 0 starts, while a boot orphan (containers present, Exited) records exactly 1. Reconciler.sleep is injected so the 30 s gap costs nothing
bootReconcileFn + runBootReconcile (package-main seam, v0.156.0) controller/cmd/controller/main.go bootrecon.New(mgr, logger).Run controller/cmd/controller/bootrecon_wiring_test.go. The wiring itself is asserted by an AST walk over func main(), not a strings.Contains — the substring version passed its own red-proof because a commented-out call still contains the string. Comments are not callers
classifyRunStates (pure fix-3 derivation, v0.164.0) controller/cmd/controller/main.go []stacks.Stack(dead []web.DeadApp, states []notify.AppRunState); scanDeployedAppRunStates = classifyRunStates(mgr.GetStacks()) classify_runstates_test.go. THE single fix-3 rule: down = IsDownState(st.State) && st.State != StateStopped. A deliberate UI stop (compose down → zero containers → StateStopped, I1) must not alarm — banner OR email — while faults (Exited/Degraded) alarm byte-identically; I2 (P2 census: all catalog services unless-stopped) is why a crash never rests at stopped. Do NOT touch IsDownState (other callers rely on stopped=down) and do NOT filter in buildDeadAppAlerts/NotifyAppStartFailures — one derivation point. If I1 or I2 changes, revisit the suppression
report.SetPendingControllerLog / SetControllerLogSource controller/internal/report/selftail.go ACK-armed consume-once self-log pull (the logtail.go shape) selftail_test.go; source = logBuffer.Lines, wired once in main.go
util.ParseVersion / util.Version.Compare controller/internal/util/version.go THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81)
agentapi.AgentVersionReporter + featureMinAgent controller/internal/agentapi/features.go version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: SupportsWithSource also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line
netProbeReadBack (package var) controller/internal/web/netprobe.go os.ReadFile overridden in TestNetProbeChild (nonce-tamper + cleanup-fail rows); package var because the child is a RE-EXEC'd process in production
system.ClassifyPathFS(Timeout) + netProbeFSClass / Server.classifyFSPath / Router.classifyFSPath controller/internal/system/fsclass*.go (+ web/netprobe.go, web/server.go, api/router.go seams) statfs f_type → network/autofs/stub/unknown in THIS namespace (RCA fix 2) idle autofs = HEALTHY, never force-mount; unknown = fail OPEN; seams injected in netprobe_stub_test.go / networkstub_test.go / deploygate_test.go
quiesce.Backend / quiesce.Stacks controller/internal/quiesce/quiesce.go adapter over *agentapi.Client / *stacks.Manager fakeBackend/fakeStacks in controller/internal/quiesce/quiesce_test.go
channelhealth.Probe (func) + Sink controller/internal/channelhealth/checker.go Server.ProbeAgentChannel / notifier adapter fakeSink in controller/internal/channelhealth/checker_test.go
appbackup.StackDataProvider controller/internal/appbackup/appdata.go *stacks.Manager (via backup.SetStackProvider) fakeRecoveryProvider in controller/internal/backup/recovery_unit_test.go
appexport.ExportStackProvider controller/internal/appexport/provider.go *stacks.Manager exercised in appexport tests
selfupdate.AgentSwapper controller/internal/selfupdate/updater.go *agentapi.Client (SwapController/SwapStatus) fakeAgent in controller/internal/selfupdate/updater_test.go
mailrelay.Forwarder controller/internal/mailrelay/forward.go HubForwarder (hub relay endpoint) fakeForwarder in controller/internal/mailrelay/mailrelay_test.go
integrations.Handler + StackProvider controller/internal/integrations/integrations.go + manager.go OnlyOffice handlers table-driven tests in package
bootstrap.PullFunc controller/internal/bootstrap/bootstrap.go report.PullConfig injected in bootstrap tests
offboxRunner (func) controller/internal/backup/offbox.go defaultOffboxRunner (restic exec) SetOffboxRunner injection point
dumpVolumesSafe (func seam) controller/internal/backup/backup.go nil → real DumpAppVolumesSafe injected in controller/internal/backup/volume_dumps_test.go (gating tests without Docker)
generateSecret (func seam) controller/internal/backup/backup.go stacks.Manager.GenerateSecretForField via SetSecretGenerator (main.go) injected in controller/internal/backup/restore_secrets_gen_test.go
restoreFilesCopier (func seam) controller/internal/backup/backup.go nil → real rsyncRestoreMissing injected in controller/internal/backup/tier2_restore_test.go (orchestration without rsync)
tier2Mirror (func seam) controller/internal/backup/backup.go nil → real rsyncMirror both RunTier2 rsync legs; injected in controller/internal/backup/tier2_test.go (resolve→mirror without rsync)
migSeams.resolveNames (func seam) controller/internal/stacks/migrate.go nil → real ResolveAppDataDirNames (compose-derived) injected in controller/internal/stacks/migrate_fs3_test.go (F-S3 appdata dir-name resolution)

Cross-repo edges:

  • controller/internal/agentapi/client.gofelhom-agent local API (/storage, /disks*, /backup*, /netstorage*, /guest/*): pinned leaf SHA-256 + per-guest bearer token from bootstrap.json.
  • controller/internal/report/pusher.gohub /api/v1/report ingest; ACK config_version drives config_refresh.go; controller/internal/notify/notifier.go → hub /api/v1/event (hub-side allowedEventTypes allowlist must include new types).
  • controller/internal/sync/sync.goapp-catalog-felhom.eu: copies ONLY docker-compose.yml + .felhom.yml per app (SHA-256 change detection); NEVER overwrites app.yaml (deployed secrets).

5. Extension points (where new features plug in)

  • New storage web endpoint: switch in ServeStorageAPI (controller/internal/web/storage_handlers.go); disk ops in ServeDiskAPI (controller/internal/web/agent_disk_handlers.go); backup in ServeBackupAPI; export in ServeExportAPI; debug in handleDebugAPI (debug-mode gated).
  • New REST endpoint: path dispatch in Router.ServeHTTP (controller/internal/api/router.go); use writeJSON + limitBody.
  • New background job: sched.Every/sched.Daily registration block in controller/cmd/controller/main.go.
  • New template function: Server.templateFuncMap (controller/internal/web/funcmap.go) — obey v2 state-suffix vocabulary.
  • New page/nav item: baseData + sidebar in controller/internal/web/templates/ (nested sub-links pattern .nav-links-nested); must pass controller/scripts/template_id_gate.py + controller/scripts/emoji_gate.py + controller/scripts/native_confirm_gate.py + controller/scripts/offbox_rename_gate.py + controller/scripts/app_row_dedup_gate.py + controller/scripts/mojibake_gate.py.
  • Docker volume tar streaming (v0.125.0): appexport.dockerExec (seam, package var) + withVolumeHelper/exportVolumeTar/importVolumeTar — stream volume content via docker cp through a stopped helper container. NEVER docker run -v <controller-local path> — the daemon resolves -v host-side and strands the data when the controller is containerized (the v0.124.0 HIGH finding); controller/scripts/docker_run_volume_path_gate.py enforces (every "-v" allowlisted with its WHY).
  • Guarded file download (v0.124.0): handler_export_download.go — the canonical shape for streaming a server-side file to the browser: accept a BASENAME only (shape regexp + no separators/..), filepath.Join then assert filepath.Dir(path) == dir, io.Copy (never ReadAll), Content-Disposition: attachment, remove after a successful stream, TTL sweep (sweepFabDownloads(dir, now, maxAge, logger) — now injected for tests). Red-proof the guard by loosening to prefix-matching (the .. case must fail).
  • Backups sub-page data: backupsCommonData(page, title, r) + backupsOffboxData(data) (handlers.go) — the ONLY builders for the four /backups* pages; a new backups section extends these, never re-derives in a page handler. (The one-shot v0.124.0 move gate backups_split_move_check.py was retired in v0.126.0.)
  • App-list row (v0.126.0): app_list_row/app_list_row_end in controller/internal/web/templates/app_row.html is THE canonical list pattern — icon+name(+secondary) left, caller action block right; open with dict "Slug" ... "Name" ... (optional Secondary/RowClass/Href/FallbackIcon), close with app_list_row_end. Do NOT hand-roll app rows — controller/scripts/app_row_dedup_gate.py enforces single-sourcing (the backups_apps expander header is the one allowlisted aligned copy). Infra display identity: inframeta.go map + infraMeta func (filebrowser is the only Linked stack).
  • Consequential-action confirm (LIGHT): felhomConfirm(el, question, onYes) in layout.html (v0.123.0) — the trigger swaps in place to "kérdés + Igen/Mégse"; form buttons opt in with data-confirm="…" (delegated listener, requestSubmit keeps formaction/name-value). NEVER native confirm()/prompt() (OS-modals freeze browser automation — drill F-11; native_confirm_gate.py enforces). Heavy destructive flows keep the .confirm-overlay openDialog pattern.
  • New hub event: typed Notify* wrapper on Notifier + hub allowlist entry (cross-repo).
  • New app integration: integrations.Manager.RegisterHandler with IntegrationKey(provider, target).
  • New startup self-check: append check fn in selftest.Run (controller/internal/selftest/selftest.go).
  • New settings field: struct + accessor pair in controller/internal/settings/settings.go following the Lock→mutate→save pattern.

6. Known duplication (observed — NOT fixed)

Duplication Locations
Atomic write ×4 (+inline in settings.save) controller/internal/backup/recovery_unit.go atomicWrite; controller/internal/bootstrap/bootstrap.go writeFileAtomic; controller/internal/setup/handlers.go atomicWriteFile; controller/internal/api/router.go writeConfig0600
String truncation ×3 controller/internal/util/strings.go TruncateStr (rune-safe, 1 caller); controller/internal/stacks/manager.go truncateStr (byte-based, widely used); controller/internal/backup/offbox.go truncate
humanizeBytes ×3 controller/internal/appbackup/appdata.go HumanizeBytes (canonical) + private twin; controller/internal/appexport/estimate.go humanizeBytes; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge)
copyFile ×2 controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go
dir-size ×6 controller/internal/stacks/delete.go getDirSizeBytes/getDirSizeHuman; controller/internal/backup/tier2.go dirSizeBytes (du -sb); controller/internal/appexport/estimate.go dirSize+duBytes; controller/internal/appexport/export.go calcDirSize; controller/internal/web/handlers.go dirSizeHuman
timeAgo switch body ×2 controller/internal/web/funcmap.go timeAgo vs timeAgoStr (identical formatting logic)
CSRF ×2 controller/internal/web/csrf.go (session HMAC) vs controller/internal/setup/csrf.go (cookie double-submit) — intentional (pre-auth wizard) but unlabeled
Budapest timezone loader ×3 controller/internal/scheduler/scheduler.go getBudapestLocation vs controller/internal/web/funcmap.go getTimezone vs controller/internal/quiesce/quiesce.go budapestLocation (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge)
JSON writers ×5, 3 envelope shapes api writeJSON; web writeDiskJSON, jsonResponse/jsonError, writeDebugJSON
Safe-name validators ×4 controller/internal/web/validate.go validStackName; controller/internal/api/router.go validStackParam (same body — api↔web import cycle); controller/internal/backup/offbox.go isSafeStackName; controller/internal/appexport/validate.go ValidateSegment (strictest)
DB wait/import ×2 controller/internal/appbackup/dbdump.go waitDBReady/ImportDump vs controller/internal/appexport/restore.go waitForDB/importDBDump
compose exec ×2 controller/internal/stacks/manager.go composeExecCustomEnv vs controller/internal/appexport/restore.go composeExecEnv (the latter has ctx+timeout; the former has the userdata belt)