docs: centralize controller documentation + top-level index (code-verified, v0.59.0)

New documentation/controller/ subtree (module map + deploy/stack-lifecycle, backup,
storage/monitoring/metrics, auth/hub/sync/integrations) grounded in current source;
top-level documentation/README.md index across controller/agent/platform/hub/audits;
REORG-NOTES with the verification ledger + flagged doc-gaps. Supersedes (keeps) the
v0.33 controller planning map. Additive only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 19:40:25 +02:00
parent 4320c01915
commit 21d0e7cf4c
8 changed files with 991 additions and 0 deletions
@@ -0,0 +1,256 @@
# Controller: deploy & stack lifecycle
> Source of truth: felhom-controller `internal/stacks/`, `internal/infra`, `internal/sync` at v0.59.0.
This document describes how the in-guest controller represents application stacks, deploys
them, runs their lifecycle (update/stop/start/restart/remove/delete), and brings up the base
infrastructure (traefik / cloudflared / filebrowser) that everything else depends on. Every
claim below is verified against the current source.
## 1. The stack model
A *stack* is one customer app, represented on disk as a directory under
`/opt/docker/stacks/<name>/` (config key `paths.stacks_dir`, default `/opt/docker/stacks`)
containing:
- `docker-compose.yml` — the compose template (synced from the catalog, see §7).
- `.felhom.yml` — app metadata (`Metadata`): display name, deploy fields, resource
requests/limits, optional config, healthcheck spec, integrations.
- `app.yaml` — the per-app deployment record (`AppConfig`), written **only after** the app
is first deployed.
In memory each stack is a `Stack` struct (`internal/stacks/manager.go:62`):
| Field | Meaning |
|---|---|
| `State` (`ContainerState`) | aggregated container state |
| `Deployed` | has an `app.yaml` with `deployed: true` |
| `Deploying` | a `compose up` is in progress (image pull, etc.) |
| `Protected` | a system stack that must never be stopped/removed (§4) |
| `Orphaned` | deployed but no longer present in the synced catalog |
| `DeployError` | last async deploy error, surfaced to the UI |
| `HealthProbe` | latest controller-side probe result (§6) |
`ContainerState` values (`manager.go:23`): `running`, `starting` (`health: starting`),
`unhealthy`, `stopped`, `restarting`, `exited`, `paused`, `unknown`, `not_deployed`,
`deploying`, `orphaned`. The effective state is derived by `resolveContainerState`
(combines Docker `State` + the health hint in `Status`) and then `aggregateState` across
all of a stack's containers, with priority `unhealthy > starting > restarting > all-running
> stopped` (`manager.go:437`, `:468`).
`ScanStacks` (`manager.go:250`) discovers stacks by walking `stacks_dir`, reading metadata
and `app.yaml`, marking `Protected` via `cfg.IsProtectedStack`, and computing `Orphaned`
against the synced catalog cache (`getCatalogTemplateSlugs`, `manager.go:1078`). While an
async deploy is in flight it deliberately does **not** overwrite `Deployed`/`AppConfig`
the deploy goroutine owns those (H3 fix, `manager.go:293`).
### `AppConfig` (`app.yaml`)
```go
type AppConfig struct {
Deployed bool `yaml:"deployed"`
DeployedAt string `yaml:"deployed_at"`
Env map[string]string `yaml:"env"`
LockedFields []string `yaml:"locked_fields"`
}
```
(`deploy.go:98`). `Env` holds the resolved deploy-field values; sensitive ones are stored
**encrypted** (§3). `LockedFields` lists env vars marked `locked_after_deploy` that
`UpdateStackConfig` refuses to change (`deploy.go:434`).
## 2. Deploy flow
Entry point: `DeployStack(req)` (`deploy.go:121`), driven by the deploy form ("Telepítés").
1. **Atomic check-and-set of `Deploying` (H1).** Under the manager lock, the stack must
exist, must not already be `Deploying`, and must not already be `Deployed`; then
`Deploying` is set true (`deploy.go:122`). This single critical section prevents two
concurrent deploys of the same app. Any later validation failure clears the flag via
`clearDeploying()`.
2. **Memory gate.** `system.GetMemoryMB()` vs the app's `mem_request` and the configured
`reserved_memory_mb`. A hard block (real used + new request > usable) aborts with a
Hungarian error; a soft over-commit case returns a non-fatal warning string
(`deploy.go:159`).
3. **Field resolution.** For each `DeployField`: `domain` is auto-filled from
`customer.domain`; `subdomain` is validated (DNS-safe, not reserved, not already in
use); `secret` uses the value previewed to the user or generates one; `password` is
**required** (never silently generated — the user must know it); `path` must exist on
the host. Required-but-empty fails (`deploy.go:206``292`).
4. **`app.yaml` is persisted with `deployed: false` first (CTRL-T2-1).** The in-memory
`AppConfig` carries `Deployed: true` (for UX), but a clone with `Deployed: false` is what
`SaveAppConfig` writes to disk at this point (`deploy.go:302``314`). The durable record
is intentionally *not* marked deployed yet.
5. **In-memory state flips to deployed** (`s.Deployed = true`, `s.AppConfig = appCfg`),
so the UI immediately stops showing a stale "Telepítés" button during the pull
(`deploy.go:331`).
6. **Async compose.** `go m.runComposeDeploy(...)` runs `docker compose up -d` off the
request path, so the HTTP handler returns at once and the UI polls for progress (an
image pull can take 3060s). The compose command injects the resolved env plus `DOMAIN`
(`deploy.go:338`, `composeExecWithEnv` `manager.go:505`).
### CTRL-T2-1 crash-safety (the key current behaviour)
The on-disk `app.yaml` records `deployed: true` **only after** `docker compose up -d`
actually succeeds. `runComposeDeploy` (`deploy.go:346`):
- On compose **failure**: reverts in-memory state (`Deployed=false`, `Deploying=false`,
records `DeployError`, `AppConfig=nil`) and re-saves the reverted `AppConfig`
(still `deployed:false`) to disk (`deploy.go:350`).
- On compose **success**: re-runs `SaveAppConfig` with `appCfg.Deployed == true`, flipping
the durable record to deployed (`deploy.go:371`). If *that* save fails, it reverts in
memory so the customer can cleanly redeploy rather than be stuck half-recorded
(`deploy.go:375`).
- Only then is `Deploying` cleared and `RefreshStatus()` run.
Why it matters: a crash or power loss **during the image-pull window** leaves `app.yaml`
at `deployed: false`. On restart, `ScanStacks` therefore sees the app as *not deployed*
(no ghost-deployed stack with no containers), and `DeployStack` will cleanly redeploy
instead of refusing with "already deployed".
The UI's three-step progress (form submit → "pulling/deploying" via the `deploying`
state and `DeployError` → settled `running`/`unhealthy`) is driven entirely by this
in-memory state, which the front-end polls.
## 3. `SaveAppConfig` and secret encryption (fail-closed, H10)
`SaveAppConfig(stackDir, cfg, encKey, sensitiveVars)` (`deploy.go:669`) clones the env and,
for each var named in `sensitiveVars` (the `secret`/`password` deploy fields, computed by
`SensitiveEnvVars`, `deploy.go:741`), encrypts the value with the AES-256 key
(`crypto.Encrypt`) unless it is empty or already encrypted.
**Fail-closed (H10, v0.59.0):** if encryption of a sensitive value errors, `SaveAppConfig`
**returns the error and writes nothing** — it never falls through to a plaintext write
(`deploy.go:683``691`). Earlier code logged a WARN and persisted plaintext, which leaked
the secret to disk; that path is gone. Callers (`DeployStack`, `UpdateStackConfig`, etc.)
already propagate this error, so the deploy fails cleanly with no secret on disk.
The write itself is atomic: write to `app.yaml.tmp` then `rename` (H04, `deploy.go:711`),
mode `0600`. Decryption for compose happens lazily via `LoadAppConfigDecrypted` /
`stackEnv` (`deploy.go:727`, `manager.go:809`), which injects `DOMAIN` plus the decrypted
env into the compose process environment. A one-time startup `MigrateEncryption`
(`manager.go:128`) re-saves any deployed app still holding plaintext sensitive values.
## 4. Lifecycle: update / stop / start / restart / remove / delete
All operations resolve the stack dir, build env via `stackEnv` (decrypted), and shell out
to compose. Notably **start/restart use `up -d`, not bare `restart`**, so env changes and
template changes (new images, healthchecks) are picked up (`manager.go:709`, `:643`).
- **Start** (`StartStack`, `manager.go:643`): `compose up -d`; clears the stale health
probe so the next probe runs fresh.
- **Stop** (`StopStack`, `manager.go:682`): refuses protected stacks, then `compose down`.
- **Restart** (`RestartStack`, `manager.go:709`): `compose up -d`; clears health probe.
- **Update** (`UpdateStack`, `manager.go:746`): `compose pull` then
`compose up -d --remove-orphans`.
- **Config update** (`UpdateStackConfig`, `deploy.go:405`): rejects locked fields, re-saves
`app.yaml` (encrypting secrets), then `up -d` with the decrypted env.
- **Remove** (`RemoveStack`, `delete.go:283`): for a *deployed* (non-orphaned) stack —
`compose down --volumes` (keeps images for redeploy), optional HDD-data and backup-path
cleanup, then deletes **only** `app.yaml`, reverting the stack to "not deployed". Compose
template files are preserved so the user can redeploy.
- **Delete** (`DeleteStack`, `delete.go:80`): for an *orphaned* stack only —
`compose down --rmi local --volumes`, optional HDD-data removal, then removes the whole
stack directory.
### Protected-stack enforcement (server-side, defence in depth)
The protected set is configured under `stacks.protected` (default
`traefik`, `cloudflared`, `felhom-controller`, `filebrowser` — see
`configs/controller.yaml.example:55` and the setup writer `internal/setup/handlers.go:476`).
`config.IsProtectedStack` matches case-insensitively (`config.go:347`). Enforcement exists
at three layers:
- **Router/API:** `actionStack` blocks any action other than `restart` on a protected
stack with HTTP 403 (`internal/api/router.go:412`).
- **Manager:** `StopStack` (`manager.go:683`), `RemoveStack` (`delete.go:289`) and
`DeleteStack` (`delete.go:86`) each independently refuse protected stacks, so the guard
holds even if a caller bypasses the router.
- The quiesce loop's `RunningAppStacks` (`manager.go:232`) also excludes protected stacks,
so the controller never stops its own tunnel/proxy or itself for a backup.
### Deploying-guard on remove/delete (H2)
Both `RemoveStack` (`delete.go:309`) and `DeleteStack` (`delete.go:106`) refuse while the
stack is `Deploying`, and both also require the stack to be stopped (not
running/starting/restarting). HDD-path deletion is gated by `ProtectedHDDPaths`
(`delete.go:58`), which refuses to wipe top-level namespace directories (`appdata/`,
`backups/`, `media/`, etc.), and `ParseComposeHDDMounts` cleans paths before the prefix
check to block traversal (C10, `delete.go:555`).
## 5. Base-infra bring-up: `EnsureBaseStack`
`EnsureBaseStack` (`internal/stacks/infra.go:27`) renders and deploys the routing/access
infrastructure. Properties (verified):
- **Single-flight:** guarded by `infraMu.TryLock()` (`infra.go:28`). It is fired both at
first boot and on every `system-health` tick (self-heal); a second concurrent invocation
returns immediately rather than racing a `compose up` on the same dir.
- **Idempotent:** each component is skipped when its container is already running
(`containerRunning`, `infra.go:256`), so the healthy-state re-run is a cheap trio of
`docker inspect` calls.
- **Non-fatal by contract:** per-component failures are collected into a joined error for
the caller to *log* — it must never crash the controller (`infra.go:67`).
Deploy order is load-bearing (the composes declare `traefik-public` as
`external: true`, so it must exist first):
1. `ensureTraefikNetwork` — creates the external `traefik-public` docker network if absent,
tolerating a create/inspect race (`infra.go:211`). A failure here is fatal to the run
(every stack `up` would fail without it).
2. `ensureTraefik` — prepares `dynamic/`, `certs/`, a `0600` `acme.json`, renders the
traefik config from `customer.email` + `infrastructure.cf_api_token`, writes it, and
`compose up -d` (`infra.go:73`).
3. `wireController` — writes the file-provider route `Host(felhom.<domain>) →
http://felhom-controller:8080` and joins the `felhom-controller` container to
`traefik-public`. Both steps are idempotent (route rewritten only when content changed,
so the traefik file watcher doesn't reload every tick; network-connect skipped when
already attached). A missing domain is a logged no-op, not an error (`infra.go:160`).
4. `ensureCloudflared` — **only when `infrastructure.cf_tunnel_token` is set**; a LAN-only
node legitimately runs without it, and `monitor.EffectiveProtected` mirrors this
condition so such a node doesn't report cloudflared as a perpetually-missing protected
container (`infra.go:55`, `monitor/healthcheck.go:252`).
5. `ensureFileBrowser` — **preserves an existing compose.** If
`filebrowser/docker-compose.yml` already exists, it `compose up -d` *without
regenerating*, so the storage mounts that `web.SyncFileBrowserMounts` manages are kept
intact. Only on first provision does it render the initial compose + `config.yaml` with
no mounts (`infra.go:122`).
## 6. Health probes
`RunHealthProbes` (`internal/stacks/healthprobe.go:23`) is a scheduler-driven, controller-side
probe (independent of Docker's own healthcheck). It only probes stacks in `running`/`unhealthy`
state that declare a `healthcheck` in metadata and whose interval has elapsed. The interval is
adaptive: a fast **10s** retry while unhealthy, otherwise the configured interval (default 5m);
when `HealthProbe` is nil (just started) it probes immediately (`healthprobe.go:41`).
Targets are collected under a read-lock, probed **concurrently** with the lock released
(`healthprobe.go:91`), and results applied under the write-lock. Check types: `tcp`
(dial), `http` (any response = healthy), and `api` (validate expected status / body
substring) — `runSingleCheck`, `probeTCP`, `probeHTTP` (`healthprobe.go:169`+). A failed
probe overrides a Docker-`running` stack to `unhealthy`; a passing probe clears that
override back to `running`. `refreshStatusLocked` re-applies the last probe result so a
status refresh never resurrects a stale healthy state (`manager.go:418`).
## 7. How stacks get their templates (catalog git-sync)
The compose templates and metadata come from the app catalog via `internal/sync`
(`Syncer`, `sync.go:22`). On a configurable interval (`git.sync_interval`, default 15m,
with an initial sync at startup) it shallow-clones/pulls `git.repo_url` (branch
`git.branch`) into `<data_dir>/catalog-cache` via `git fetch --depth 1` +
`reset --hard origin/<branch>` (`sync.go:247`). It then copies **only** `docker-compose.yml`
and `.felhom.yml` from each `templates/<app>/` into `stacks_dir/<app>/`, hashing content
to skip unchanged files and **never** overwriting `app.yaml` (`copyTemplates`,
`sync.go:311`; `copyIfChanged`, `sync.go:395`).
After a sync that changed anything, it triggers a rescan (`ScanStacks`) and, for *updated*
stacks, a post-sync hook that runs `InjectMissingFields` — auto-generating values for any
new `secret`/`domain`/`subdomain` deploy fields that aren't yet in a deployed app's
`app.yaml` (`sync.go:207`, `deploy.go:801`). `TriggerSync` (manual) debounces to one run
per 30s. Sync is disabled (manual mode) when `git.repo_url` is empty (`sync.go:79`). The
catalog cache also feeds orphan detection (`getCatalogTemplateSlugs`): a deployed stack
whose slug is no longer in the cache is flagged `Orphaned`.