Closes the two findings from DIAG-immich-restore-2026-07-19. Viktor deleted 11
immich photos to test offsite restore; both runs flashed success and the photos
stayed gone. Two independent defects.
R-43 — no offsite path could restore a database. All three buttons were
file-only: the two "visszaállítás" actions staged to a scratch folder and never
touched postgres, and place-to-live merged only MISSING files. For a DB-indexed
app the bytes returned and the app still could not see them. The dump was
carried INTO every snapshot and could never be replayed OUT of one.
New ReconstituteFromOffsite (/backup/offbox/reconstitute): safety dump → stop →
files overwritten to the snapshot version → start → the snapshot's own dump
replayed → health wait. Two invariants:
- nothing is ever deleted (-a, no --ignore-existing, no --delete): a file
created after the snapshot survives as an extra;
- the undo exists before the act — the pre-restore- dump is verified ON DISK
before anything is stopped, overwritten or replayed; if it cannot be taken
the operation refuses with zero changes.
The replay reads the SCRATCH unit: the live unit is never overwritten, so
replaying from it would replay the current DB over itself and restore nothing.
R-44 — a manual push shipped an unrefreshed dump (up to ~24h old). That day's
predated the customer's account by four hours and probed to asset:0/user:0/
album:0 inside 52MB whose bulk was immich's shipped geodata. Every run, manual
AND nightly, now refreshes dumps + units BEFORE capturing. Order is the
mechanism: the gap can only ADD files the DB does not reference yet, never
remove one it does. Manifests carry offsite_run_id + dumps_at, so coherence is
verifiable at restore time rather than assumed; the periodic refresh carries a
prior stamp forward and never invents one.
Honesty surfaces, all warn-level and none a gate: unstamped (pre-v0.148) pairs
report their skew, ValidateDump gained an EXACT-match accounts-table sniff for
customer-empty dumps, the completion flash states an outcome instead of a
mechanism, and the missing-only button now says what it does NOT do.
11 tests; 5 red-proofs run and reverted. Two of those found real test weaknesses
rather than confirming strength — the first undo mutation was caught by a second
guard, and the first table-matching test did not discriminate between the two
matchers at all. Both tests were rewritten to the cases that separate them.
NOT in scope: R-41's catalog invariant check, nightly cadence, retention, quota
math, tier-2, and v0.147.x progress semantics beyond one added phase line.
Live acceptance (§9) has NOT run: no capability-map flip, customer-restore row
stays MISSING, R-3 stays DRAFT.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Nn14TWGzKoqAJAiVwC2s
49 KiB
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 |
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.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 |
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_version → ConfigRefresher.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 |
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) |
getBudapestLocation |
controller/internal/scheduler/scheduler.go | () *time.Location |
Local-time math | web has its own getTimezone (§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; 1–3 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 (func seams) |
controller/internal/stacks/manager.go (fields) + samba.go | nil → composeUp / docker exec smbpasswd (STDIN) / containerRunning("felhom-samba") |
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 |
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 |
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 |
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_appdata_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.go↔ felhom-agent local API (/storage,/disks*,/backup*,/netstorage*,/guest/*): pinned leaf SHA-256 + per-guest bearer token from bootstrap.json.controller/internal/report/pusher.go→ hub/api/v1/reportingest; ACKconfig_versiondrives config_refresh.go;controller/internal/notify/notifier.go→ hub/api/v1/event(hub-sideallowedEventTypesallowlist must include new types).controller/internal/sync/sync.go← app-catalog-felhom.eu: copies ONLYdocker-compose.yml+.felhom.ymlper app (SHA-256 change detection); NEVER overwritesapp.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 inServeDiskAPI(controller/internal/web/agent_disk_handlers.go); backup inServeBackupAPI; export inServeExportAPI; debug inhandleDebugAPI(debug-mode gated). - New REST endpoint: path dispatch in
Router.ServeHTTP(controller/internal/api/router.go); usewriteJSON+limitBody. - New background job:
sched.Every/sched.Dailyregistration 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 passcontroller/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 viadocker cpthrough a stopped helper container. NEVERdocker run -v <controller-local path>— the daemon resolves-vhost-side and strands the data when the controller is containerized (the v0.124.0 HIGH finding);controller/scripts/docker_run_volume_path_gate.pyenforces (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.Jointhen assertfilepath.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 gatebackups_split_move_check.pywas retired in v0.126.0.) - App-list row (v0.126.0):
app_list_row/app_list_row_endintemplates/app_row.htmlis THE canonical list pattern — icon+name(+secondary) left, caller action block right; open withdict "Slug" ... "Name" ...(optionalSecondary/RowClass/Href/FallbackIcon), close withapp_list_row_end. Do NOT hand-roll app rows —scripts/app_row_dedup_gate.pyenforces single-sourcing (the backups_apps expander header is the one allowlisted aligned copy). Infra display identity:inframeta.gomap +infraMetafunc (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 withdata-confirm="…"(delegated listener,requestSubmitkeeps formaction/name-value). NEVER nativeconfirm()/prompt()(OS-modals freeze browser automation — drill F-11;native_confirm_gate.pyenforces). Heavy destructive flows keep the.confirm-overlayopenDialogpattern. - New hub event: typed
Notify*wrapper on Notifier + hub allowlist entry (cross-repo). - New app integration:
integrations.Manager.RegisterHandlerwithIntegrationKey(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 ×2 | controller/internal/scheduler/scheduler.go getBudapestLocation vs controller/internal/web/funcmap.go getTimezone |
| 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) |