diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af1256..104bc64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## Changelog +### docs — REUSE.md introduced (2026-07-03) + +Cross-repo reuse-map rollout (docs-only, no code change, no version bump). New `REUSE.md` at the +repo root: curated map of canonical helpers (62 rows), patterns, dangerous lookalikes (rsyncMirror +`--delete`, raw os.RemoveAll on drive paths, fresh agentapi.New per request…), test seams, extension +points, and observed duplication (12 clusters, NOT fixed). Every entry code-verified at file+symbol; +cited paths machine-checked by `felhom.eu/scripts/reuse_refs_check.py` (green). CLAUDE.md gains the +"See REUSE.md before writing new code" pointer + the same-commit maintenance rule. + ### v0.98.3 — hide "Eltávolítás a listából" on wizard-enrolled drives (2026-07-02) User feedback follow-up: list-removal (registry-entry delete; data + mount untouched) is only diff --git a/CLAUDE.md b/CLAUDE.md index 6e95f82..53ffc0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ See `controller/README.md` for full architecture and status (update after each s See `CHANGELOG.md` for recent work (update after each session — see "Working with CHANGELOG.md" below). See `CONTEXT.md` for current project state, decisions and roadmap (update after each session). See `TASK.md` for the current task to implement (if it exists). +See `REUSE.md` before writing new code. Claude in Chrome extension is available — can be used to test web UI on demo-felhom.eu or verify dashboard deployments in browser. @@ -334,4 +335,5 @@ Before ending a session, always: 3. **Update CHANGELOG.md** with what was done 4. **Update CONTEXT.md** with decisions made, update architectural state and what's next 5. **Update controller/README.md** if architecture or features changed -6. **Verify** the deployment is working (check `docker ps` and logs) \ No newline at end of file +6. **Verify** the deployment is working (check `docker ps` and logs) +7. **Update REUSE.md** if you added/changed/deprecated a shared helper or pattern (same commit) \ No newline at end of file diff --git a/CONTEXT.md b/CONTEXT.md index 47c862f..31fa59e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,9 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-02 (v0.98.0 — Tárhely split into Meghajtók + Hálózati tárhely subpages; live on 9201) +Last updated: 2026-07-03 (docs: REUSE.md introduced) + +> **2026-07-03 — `REUSE.md` exists at the repo root** (canonical helpers / patterns / traps / seams, code-verified). Check it before writing new code; update it in the same commit that adds/changes a shared helper (maintenance rule now in CLAUDE.md). > **2026-07-02 — v0.98.0 (deployed on 9201): Tárhely IA follow-up.** User feedback on D1: NAS-add and > local-drive enrollment buttons sat side by side — confusing. `/storage` split into two subpages diff --git a/REPORT.md b/REPORT.md index 7cb9c0a..cf91938 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,38 +1,44 @@ -# REPORT — Storage IA follow-up: Meghajtók / Hálózati tárhely subpages → v0.98.0 +# REPORT — docs: REUSE.md introduced (cross-repo reuse map) -**Date:** 2026-07-02 · **Deployed:** guest 9201 (`gitea.dooplex.hu/admin/felhom-controller:0.98.0`, healthy) -**Trigger:** user feedback on the D1 Tárhely page — the NAS-add button and the local-drive enrollment -buttons ("Új meghajtó inicializálása" / "Meglévő meghajtó csatolása") sat side by side with no -separation; two different storage classes confusingly interleaved. +**Date:** 2026-07-03 · **Class:** docs-only (no code change, no version bump, no deploy) +**Baseline:** `main` @ `d0c70a7da6f4060422a545cea2396f9df5f7d61f` (v0.98.3) -## What changed (commit `d3c97c6` on `main`, from `c427b40`) +## What was done -- **`/storage` — Tárhely — Meghajtók** (storage.html): physical drives only — registry cards + - unified agent view + migrate + the wizard entry points + manual add. The enrollment buttons now - live unambiguously in the local-drive context (directly under the drive cards). -- **`/storage/network` — Tárhely — Hálózati tárhely (NAS)** (storage_network.html, new): the NAS - share list ("NAS-megosztások") + add form + its JS moved verbatim, incl. its own `openDialog` - copy for the remove overlay (per the D1 no-shared-bundle convention). -- **Sidebar:** the Tárhely main-nav item gains two always-visible nested sub-links (Meghajtók / - Hálózati tárhely; new `.nav-links-nested` CSS); the parent stays highlighted on both subpages. -- **Go:** `networkStoragePageData()` (page key `storage-network`) split out of `storagePageData()`; - `storageNetworkPageHandler`; `GET /storage/network` route. **No `/api/storage/*` or storage - semantics change.** +- **`REUSE.md`** created at the repo root (210 lines): the curated reuse map — canonical helpers + (62 rows across 9 domains), canonical patterns (10), dangerous lookalikes (8), test seams + + cross-repo edges, extension points, and observed duplication (12 clusters, recorded NOT fixed). +- Sourced from a full code sweep (1174 non-test `func` declarations scanned; call-site counts + + body reads per candidate; mandatory trap sweep over `--delete`/`os.RemoveAll`/`mkfs`/etc.). + Every entry cites file + symbol, verified in code — not copied from README/docs. +- **`CLAUDE.md`**: two hook lines added — "See `REUSE.md` before writing new code" (read-first + pointers) and end-of-session checklist item 7 (update REUSE.md in the same commit as a shared + helper change). No other CLAUDE.md edits. +- `CHANGELOG.md` entry (no version number) + `CONTEXT.md` note. ## Verification -- `go build ./... && go vet ./... && go test ./...` — exit 0, 18 packages ok. Tests updated: - `/storage` must NOT render the NAS section, `/storage/network` renders it and nothing - drive-related; the h3 inventory + no-native-confirm scans include the new template. -- Template gates: `template_id_gate.py` OK, `emoji_gate.py` 0. -- **Live (claude-in-chrome):** both subpages render with correct sidebar active states (parent + - child); the drives page shows the enrollment buttons directly under the cards with the - Rendszermeghajtók group below and no NAS content; the NAS page's add-form toggle, `nsToggleSmb` - protocol switch, and `openDialog` were exercised on the moved JS. Sidebar shows 0.98.0. +`python felhom.eu/scripts/reuse_refs_check.py e:/git/felhom-controller` → +`OK [felhom-controller]: 65 cited paths, all exist` (the check caught and fixed one wrong citation: +the template/emoji gate scripts live at `controller/scripts/`, not `scripts/`). -## Notes +## Notable traps recorded (§3 highlights) -- Storage flashes still land on `/storage` (all storage POST actions are drive actions; NAS uses - fetch + in-page reload). -- The prior TASK-D1 report (v0.97.0, four-page settings split, unified drive view, overlay - migration, red-proofs and gate outputs) is preserved in CHANGELOG v0.97.0 and CONTEXT.md. +`rsyncMirror` (`--delete`, tier-2 only) vs `rsyncCopy`; raw `os.RemoveAll` vs the +`ProtectedHDDPaths`-gated delete path; fresh `agentapi.New` per request (the v0.74.0 conn-leak +incident); `timeAgo` on string timestamps; `DumpAppVolumes` on a running DB app; +no-timeout `composeExec` for new long-running calls. + +## Observations (NOT acted on) + +- Atomic-write helper exists ×4 (+1 inline) with differing semantics; two of them have a deliberate + NON-atomic bind-mount fallback — a convergence candidate needs care. +- `util.TruncateStr` (rune-safe) has 1 caller while the byte-based `stacks.truncateStr` twin is the + widely used one. +- 5 JSON writers / 3 envelope shapes; 6 dir-size implementations; 2 CSRF implementations + (intentional but unlabeled). Full list in REUSE.md §6. +- CLAUDE.md "Workspace layout" lists ~7 internal packages; code has ~30. Its bare-metal + `/opt/docker` deploy steps are superseded by the 9201 bootstrap mechanism (already flagged + in-file). A CLAUDE.md refresh is a separate task. + +Cross-repo summary + full sweep counts: `felhom.eu/REPORT.md`. diff --git a/REUSE.md b/REUSE.md new file mode 100644 index 0000000..8338855 --- /dev/null +++ b/REUSE.md @@ -0,0 +1,210 @@ +# 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/` ↔ 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) | +| `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-` + 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) | +| `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 | +| `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 | + +### 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 | + +### 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` | +| `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` | controller/internal/web/logbuffer.go | ring buffer io.Writer | In-memory log capture for debug UI | — | +| `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 | `rsyncCopy` + `rsyncVerify` (controller/internal/stacks/migrate.go) for any move/copy; they are documented "NEVER --delete" | +| 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 | +| `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 | + +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/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.go` ← **app-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`. +- **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 ×3 | controller/internal/web/validate.go `validStackName`; 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) |