Files
felhom-controller/REUSE.md
T

36 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
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
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
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
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
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)

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.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 controller/internal/sync/sync.go TriggerSync 30s debounce, Status() snapshot struct, post-sync hook fan-out
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
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)
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)

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 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 — 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 ×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)