Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 KiB
Controller — storage, monitoring and metrics
Source of truth: felhom-controller internal/agentapi/, internal/web storage handlers,
internal/metrics/, internal/monitor/, internal/system/dockervol.go at v0.59.0; disk
classification/execution is the host agent's.
This document describes what the in-guest controller actually does with storage, host health and metrics. The controller is de-privileged (slice 8C): it is Docker-only, holds no Proxmox/disk credentials, and does not scan, format, mount or classify disks itself. Disk topology and classification come from the host agent; the controller calls the agent over a pinned local API, displays the agent's view, and keeps a small registry of the user-data drives it cares about.
1. Storage model in the controller
There are two distinct storage views in the controller, and they must not be conflated:
1a. The registered user-data drive registry (settings.StoragePath)
The controller persists a list of user-data drives it manages, in settings.json as
StoragePaths (internal/settings/settings.go:97, the StoragePath struct). Each entry is a
mount the customer's apps deploy their large files onto:
| Field | Meaning |
|---|---|
Path |
the in-guest mount, e.g. /mnt/felhom-usb |
Label |
friendly Hungarian name (Külső HDD 1TB) |
IsDefault |
new apps default here |
Schedulable |
new apps may be deployed here |
Disconnected / Decommissioned |
lifecycle flags |
MigratedTo |
target path after a decommission |
GetStoragePaths() (settings.go:439) returns a defensive copy under RWMutex. This registry
is only the external/user-data drives — the agent's system disk (local-lvm), local
templates dir and PBS targets are never in it (they live host-side and are surfaced via the
agent's host-metrics view, §4). All public settings methods take the mutex; writes are atomic
(write .tmp, rename).
1b. The agent's authoritative disk topology
A "storage target" / "disk" in the operational sense is whatever the agent reports. The controller learns topology by asking the agent, never by inspecting devices:
Client.Disks()→ agentGET /disks→[]DiskInfo(internal/agentapi/client.go:227,:306). EachDiskInfocarries the agent's authoritativeRole(system | backup | user-data),DataBearing,DataReason, capacity, and aDurableID(uuid:<fs-uuid>for usb/local-dir).DiskInfo.FSUUID()(client.go:252) strips theuuid:prefix — this is the only way the de-privileged controller learns a mount key it cannot read off the device itself.Client.HostMetrics()→ agentGET /host/metrics→ host health +[]StorageTarget(client.go:459,:492).
Classification is the agent's; the controller displays it. The UI is driven from
DiskInfo.Role: system/backup get a lock badge and no destructive controls; user-data is
customer-manageable (client.go:235-237).
1c. Drive-absent gate: userdata MkdirAll only on a live mountpoint (v0.75.0)
The runtime drive gate planDriveGates (web/intermediary.go) stops apps when an external drive
(under StableParentDir = /mnt/felhom-drives) is absent and re-attaches them on return (system/local
paths excluded). Two MkdirAll-into-<drive>/userdata sites are now consistent with that gate so
they never write into an unmounted mountpoint (which would land app data on the guest rootfs,
shadowed when the drive returns):
- Deploy belt —
stacks.ensureUserdataMountsskips whenHDD_PATHis an external root (!= sysDataPath) that is not a live mountpoint (Manager.isMountPoint, defaults tosystem.IsMountPoint). The app is held byplanDriveGates. - FileBrowser sync —
web.syncFileBrowserMountsskips (and does not mount) a registered path underStableParentDirthat is not a live mountpoint (pure helperweb.skipFileBrowserPath).
The system/local path is never gated (it is legitimately not a mountpoint, so EnsureUserdataDir
must always run there). This eliminated the campaign-#2 mkdir …/userdata: permission denied +
transient Created flapping during a drive-absent window.
Boot-time caveat (separate cause): a
mkdir … permission deniedat daemon boot is NOT the belt — docker's boot-restore auto-starts drive-backed containers (restart: unless-stopped) before the agent mounts the drives;planDriveGatesrecovers them after mount convergence. Decision deferred (see the tests/ finding-1 diagnosis doc).
2. Disk operations are delegated to the agent
All disk execution (list/assign/eject/format) goes through agentapi.Client to the agent's
/disks endpoints. The controller's web handlers are thin proxies
(internal/web/agent_disk_handlers.go), wired behind RequireAuth + CsrfProtect:
| Route | Proxies to | Notes |
|---|---|---|
GET /api/disks |
agent GET /disks |
list; sorted Go-side for stable order |
POST /api/disks/assign |
agent POST /disks/assign |
benign mount of an existing fs |
POST /api/disks/eject |
agent POST /disks/eject |
safe-unmount (data preserved) |
POST /api/disks/format |
agent POST /disks/format |
data-bearing gated agent-side |
agentClient() (agent_disk_handlers.go:43) builds a pinned client from cfg.LocalAPI
(endpoint/token/fingerprint); it returns "agent not configured" on an unprovisioned guest.
The data-bearing gate is enforced on the agent, not the controller. FormatDisk()
(client.go:372) sends the caller's device/fstype/confirmed/durable_id; the agent inspects
the device itself, tiers it by role (its own classification — the controller's claim is
ignored), and:
- blank device → formatted;
- user-data, data-bearing, not confirmed →
ErrNeedsConfirmationwith the durable id to type-to-confirm against (a customer confirmation, not an operator signature); - system/backup, data-bearing →
ErrFormatRefusedwith aPendingOpcarrying the exact offlinefelhom-opsigncommand (PendingOp.OpsignCommand(),client.go:292).
The controller holds no destructive authority — there is no force-format path. The format
handler surfaces both refusals as HTTP 409 (agent_disk_handlers.go:192-200); the deep gate
mechanics live on the agent side (cross-ref the agent's destructive-path / AGENT-001 docs).
Eject and the guided init/wipe flows
internal/web/storage_handlers.go orchestrates the guided init/attach/wipe over the same agent
endpoints plus the local registry:
- Init (
runStorageInit,storage_handlers.go:83): format → (confirm/refuse?) → resolve the new fs UUID by re-listing disks → benignassign→ register in the StoragePath registry →guest-attachinto this guest. On any refusal it performs no further destructive or mount action. - Wipe (
handleStorageWipe,:357): customer-confirmed wipe of a user-data drive only; server-side type-to-confirm (the typed name must equal the mount basename). A system/backup-protected device is refused by the agent even though the controller sendsconfirmed:true(:402-406). - Eject (
handleStorageEject,:506): benign unmount via the agent (data preserved) + deregister the StoragePath + resync FileBrowser mounts. The eject role-gate is implicit: eject is non-destructive, but the destructive wipe behind it is the agent's role-tiered gate. The agent'sEjectResultreturnsDependentGuestsso the UI can warn about other guests bound to the drive (client.go:343). - Restricted to
/mnt/<name>(validated bymountNameRe,storage_handlers.go:39); onlyext4/xfsoffered (the agent re-validates).
3. The v0.58 infra-protection prevention layer (internal/system/dockervol.go)
After the OS/Docker-data split, /var/lib/docker is a dedicated volume holding all images,
overlay and named volumes — both infra (controller/traefik/cloudflared/filebrowser) and customer
apps. Infra is protected by prevention, not placement: a reserved buffer the controller
refuses to deploy into.
DockerVolumePath = "/"(dockervol.go:16). The controller container's root is an overlay whose upperdir lives on the guest's/var/lib/dockervolume, sostatfs("/")reports that volume's capacity/free (true with the golden's overlay2 driver; pre-split it was the rootfs — correct either way).DockerVolumeReserveGB(totalGB)(dockervol.go:23) =max(5 GB, 10% of total). (10% rather than the Tier-2 guard's 20%: 20% of a large data volume would reserve an absurd amount.)GetDockerVolumeHeadroom()(dockervol.go:43) measures the volume and returnsDockerVolumeHeadroom{TotalGB, AvailGB, ReserveGB, BelowReserve, OK}.BelowReserveisAvailGB <= ReserveGB(:53).OK=falsewhen stats are unreadable — callers MUST fail-open (a transient measurement error must not block all deploys; the buffer is a safety net, not a security control).
Deploy gate (internal/api/router.go:353): deployStack refuses a new deploy with
HTTP 507 + a Hungarian message when hr.OK && hr.BelowReserve. The fail-open is explicit —
the gate only engages when hr.OK is true.
UI pre-warning (internal/web/handlers.go:339): for a NEW deploy only (an existing app's
config save consumes no fresh image space), the deploy page sets DockerBelowReserve +
human-readable free/reserve, and deploy.html shows a warning banner and disables the
"Telepítés indítása" button when below reserve.
4. Host metrics (agent-sourced host health view)
The de-privileged controller sees only its own cgroup, so it cannot read host health itself.
ServeHostMetricsAPI (internal/web/agent_host_metrics_handler.go:20, behind RequireAuth,
read-only) proxies GET /api/host-metrics → agent GET /host/metrics and returns the host-wide
view: cpu%/mem/load/uptime/cpu-temp (HostMetrics, client.go:431) plus per-storage capacity +
SMART/thin-pool health (StorageTarget, client.go:459).
v0.57 server-side enrichment (enrichHostStorageTargets, agent_host_metrics_handler.go:51):
the agent enumerates storages via pvesm in non-deterministic order, so the
#host-storage-bars list reordered on every poll. The handler now:
- Sorts
StorageTargetsstably bystorageTypeRank(:67): user-data drives (usb/local-dir) → internal SSD (lvmthin/lvm) →localtemplates+backups → backup targets (pbs/nfs/cifs) → other; alphabetical by id within a tier. - Attaches a friendly Hungarian
Label+ one-linePurposeper entry (storageLabelAndPurpose,:84). These are display-only controller-side fields (StorageTarget.Label/Purpose,client.go:476-480) — the raw PVE storage id stays inNameand is never renamed.
The disk-overview list (GET /api/disks) is similarly sorted Go-side by role
(sortDisksForView, agent_disk_handlers.go:87) for a stable, user-data-first order.
buildStorageBars (internal/web/handlers.go:50) is the separate monitoring-page
"Tárolók kapacitása" list. It iterates the registered user-data StoragePaths (not the agent
view), skips decommissioned drives, reads local system.GetDiskUsage per path, and sorts by
Path. Every bar carries the same storageBarPurpose text (:46) because this list is
all-user-data by construction — the agent's system/PBS storage is not in this registry (it is on
the storage-management page via the agent host-metrics view).
5. Metrics subsystem (internal/metrics/)
SQLite store (store.go)
NewMetricsStore(dbPath) opens modernc.org/sqlite at metricsDBPath, hardcoded in main.go:189 to
/opt/docker/felhom-controller/data/metrics.db. On the live bootstrap-managed guest this coincides
with the volume-backed data_dir (the felhom-controller-data volume mounts at
/opt/docker/felhom-controller; data_dir: /opt/docker/felhom-controller/data), so metrics persist
across restarts. The path is hardcoded rather than derived from cfg.Paths.DataDir — a latent fragility
(would diverge if data_dir were reconfigured), tracked as a low-priority backlog item, not a live bug.
The store verifies WAL mode took effect — it
errors out if PRAGMA journal_mode=WAL does not return "wal" (store.go:27-34), then sets
synchronous=NORMAL and busy_timeout=5000. Two tables: system_metrics and
container_metrics, with ts indices (:48-77). Queries downsample into time buckets
(QuerySystemMetrics/QueryContainerMetrics, default resolution 200 points). Prune deletes
rows older than a cutoff.
Collector (collector.go)
MetricsCollector.Start(ctx) runs a 60-second loop guarded by sync.Once
(collector.go:38,53), wired in cmd/controller/main.go:203 and stopped on shutdown. Each tick:
- System sample:
system.GetInfo(hddPath, cpuCollector)→ cpu%, mem, temp, loadavg, SSD + HDD usage (sampleSystem,:81). - Container sample (
sampleContainers,:99): runsdocker stats --no-streamunder a cancellable 30s timeout context derived from the loop ctx (:100), parses the tab-separated cpu/mem/net/block columns, and batch-inserts.
Telemetry + log scanner
GetContainerTelemetry(since)(telemetry.go:20) aggregates per-container avg/peak memory + avg cpu over a window from the DB, then patches in the most-recent memory per container.ScanContainerLogs(names, since, logger)(logscanner.go:52) scans each container'sdocker logs --since=<m> --tail=1000sequentially (to avoid load spikes), under a per-container 10s timeout (:87). It classifies lines on the first 5 words against error/warn keyword sets (error|fatal|panic|crit|oom|killed|exception|traceback;warn|warning), deduplicates by a normalized fingerprint (ANSI/timestamp stripped; UUIDs, long hex and 6+ digit runs collapsed), and caps each container at its 10 most-frequent issues.
These two feed the hub report, not a live UI: internal/report/telemetry.go:18
(buildAppTelemetrySection) collects 15-minute telemetry + a log scan for every non-protected,
deployed, running stack plus the controller container itself (controllerContainerName = "felhom-controller"), merges per-app issues (capped at 10), and emits []AppTelemetry pushed to
the hub.
6. Monitoring (internal/monitor/healthcheck.go)
RunHealthCheck(cfg, cpuCollector, storagePaths, logger) (healthcheck.go:26) produces a
HealthReport{Status: ok|warn|fail, Issues, Warnings, Info}. It is invoked at startup, on a
schedule, and from the debug endpoint (cmd/controller/main.go:273,558;
internal/web/handler_debug.go:255). Checks:
- SSD disk usage —
sysInfo.DiskPercentisstatfs("/"), i.e. the same Docker-data volume the prevention layer guards; warn atDiskWarnPercent/ crit atDiskCritPercent. These trip above the 10%-free reserved buffer, so the customer is warned before the deploy gate engages (:47-76). - HDD usage (when
HDDConfigured), memory, CPU, temperature — each against its configured threshold (:79-145). - Docker reachable (
docker info,:148). - Protected containers running — using
EffectiveProtected(cfg)(:252), which dropscloudflaredwhen no tunnel token is configured so a LAN-only node is not perpetually FAIL. - Storage paths (
checkStoragePaths,:279): per registered StoragePath, warn on disconnected/inaccessible, warn (not fail) when not on a separate mount point, warn ≥90% / issue ≥95% usage. All messages Hungarian.
Status rolls up to fail if any issue, else warn if any warning.
Removed: watchdog and pinger (slice 8C)
The old storage watchdog (disk disconnect/reconnect detection) and the pinger were
deleted when the controller was de-privileged — disk detection moved to the host agent
(cmd/controller/main.go:435 notes the watchdog moved host-side; monitor/ now contains only
healthcheck.go + tests). Healthchecks pinging is likewise retired: ping UUIDs in config are
logged as no-ops and the hub now owns monitoring (cmd/controller/main.go:209-214).
Cross-references
- Agent disk classification, the data-bearing gate and the signed destructive path: the host agent's storage / AGENT-001 docs.
- Backup capture/restore and Tier-2 off-drive copies:
controller/backup-architecture.md. - Deploy flow and the headroom gate in context:
controller/deploy-and-stack-lifecycle.md.