Files
felhom.eu/documentation/controller/storage-monitoring-metrics.md
T
2026-06-22 20:55:10 +02:00

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() → agent GET /disks[]DiskInfo (internal/agentapi/client.go:227, :306). Each DiskInfo carries the agent's authoritative Role (system | backup | user-data), DataBearing, DataReason, capacity, and a DurableID (uuid:<fs-uuid> for usb/local-dir). DiskInfo.FSUUID() (client.go:252) strips the uuid: prefix — this is the only way the de-privileged controller learns a mount key it cannot read off the device itself.
  • Client.HostMetrics() → agent GET /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 beltstacks.ensureUserdataMounts skips when HDD_PATH is an external root (!= sysDataPath) that is not a live mountpoint (Manager.isMountPoint, defaults to system.IsMountPoint). The app is held by planDriveGates.
  • FileBrowser syncweb.syncFileBrowserMounts skips (and does not mount) a registered path under StableParentDir that is not a live mountpoint (pure helper web.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 denied at daemon boot is NOT the belt — docker's boot-restore auto-starts drive-backed containers (restart: unless-stopped) before the agent mounts the drives; planDriveGates recovers 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 → ErrNeedsConfirmation with the durable id to type-to-confirm against (a customer confirmation, not an operator signature);
  • system/backup, data-bearing → ErrFormatRefused with a PendingOp carrying the exact offline felhom-opsign command (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 → benign assign → register in the StoragePath registry → guest-attach into 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 sends confirmed: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's EjectResult returns DependentGuests so the UI can warn about other guests bound to the drive (client.go:343).
  • Restricted to /mnt/<name> (validated by mountNameRe, storage_handlers.go:39); only ext4/xfs offered (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/docker volume, so statfs("/") 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 returns DockerVolumeHeadroom{TotalGB, AvailGB, ReserveGB, BelowReserve, OK}. BelowReserve is AvailGB <= ReserveGB (:53). OK=false when 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:

  1. Sorts StorageTargets stably by storageTypeRank (:67): user-data drives (usb/local-dir) → internal SSD (lvmthin/lvm) → local templates+backups → backup targets (pbs/nfs/cifs) → other; alphabetical by id within a tier.
  2. Attaches a friendly Hungarian Label + one-line Purpose per entry (storageLabelAndPurpose, :84). These are display-only controller-side fields (StorageTarget.Label/Purpose, client.go:476-480) — the raw PVE storage id stays in Name and 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): runs docker stats --no-stream under 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's docker logs --since=<m> --tail=1000 sequentially (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:

  1. SSD disk usagesysInfo.DiskPercent is statfs("/"), i.e. the same Docker-data volume the prevention layer guards; warn at DiskWarnPercent / crit at DiskCritPercent. These trip above the 10%-free reserved buffer, so the customer is warned before the deploy gate engages (:47-76).
  2. HDD usage (when HDDConfigured), memory, CPU, temperature — each against its configured threshold (:79-145).
  3. Docker reachable (docker info, :148).
  4. Protected containers running — using EffectiveProtected(cfg) (:252), which drops cloudflared when no tunnel token is configured so a LAN-only node is not perpetually FAIL.
  5. 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.