37 KiB
37 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 |
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 | — |
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) |
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.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) |
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 |
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.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) |