feat(agent): collect and store host diagnostic bundles - #20
Conversation
Adds a diagnostics package that gathers evidence about why a host failed: OOM kills from dmesg, filesystem usage, the busiest processes, container and systemd unit state, and Proxmox guest status. Collectors take an injected Runner, so they are tested against recorded command output rather than the host the tests run on. Each command is individually timeout-bounded, since a wedged host is exactly when diagnostics matter most. Sections degrade independently: a host that denies dmesg, has no Docker, or is not a Proxmox node still produces a useful bundle, with the unavailable collectors recording their own error. Pseudo filesystems are listed but never reported full — they permanently report 100% capacity, and a false "disk full" would drive a wrong diagnosis.
Adds migration 11 with an incident_diagnostics table and a POST /api/ingest/diagnostics endpoint authenticated with the same agent bearer token as metric ingest. The bundle is stored verbatim as JSON: the agent owns the bundle schema, so collectors can be added or changed without a matching server-side migration. incident_id is optional. An on-demand bundle describes a host at a moment in time and need not belong to an incident; when set, diagnostics cascade with the incident. collected_at records receipt time so ordering stays consistent across agents with skewed clocks. The agent's own timestamp survives inside the stored payload.
Collects a single diagnostic bundle and prints it. When --server and --token are supplied it uploads the bundle as well. Printing works with no server configured at all, which is the only mode available when Pulse itself is unreachable — the case where a bundle is most needed. Extracts the shared postJSON helper from Push so both ingest paths use one request path.
memetics19
left a comment
There was a problem hiding this comment.
Review decision: Request changes
The fixed-command design, per-section degradation, authenticated agent ownership, and test seams are solid. I found seven actionable correctness/lifecycle issues in the inline comments. The highest-risk path is a degraded host: sequential timeouts can consume the full parent deadline, and the configured upload then starts with an expired context.
I also verified the three-dot PR diff with git diff --check, gofmt, go vet ./..., full tests, and race-enabled tests in agent, api, and cli; all passed. Current GitHub checks are green. One important coverage gap remains: .github/workflows/ci.yml vets/tests api but not the newly changed agent module. Please add the agent module to CI and include blocking-runner, real stderr, malformed-output, CLI flag/exit, bundle-shape, and retention tests with the fixes.
An OOM kill says a service died; the log says why. The agent now pulls the recent journal for units already in the failed state and recent output for containers already stopped. Log targets are derived from what the host reports as broken, never taken from the server, so this adds no new input the server can influence. Capture is bounded so a bundle stays under the server's 1 MiB request cap: the last 200 lines, at most 5 targets per section, truncated to 32 KiB each. Truncation keeps the tail, where the failure is. Also folds a failing command's own output into its section error. A bare "exit status 1" is useless in a tool whose whole job is explaining failures — "cannot connect to the docker daemon" and "usage: sudo dmesg" are the actual diagnoses.
… credentials Collection could consume the whole diagnose deadline, so PushDiagnostics inherited an expired context and the upload failed on exactly the degraded hosts this feature targets. The upload now runs on a context detached from collection; Pusher still bounds the request with its own client timeout. A wedged host therefore yields a partial bundle that still reaches Pulse. Suppressing tmpfs and overlay hid real incidents: a full tmpfs is memory-backed exhaustion and a full overlay is a container's writable layer filling up. Suppression is now limited to mounts that read 100% by design. Because df -P names the device rather than the filesystem type, read-only image mounts are matched by /dev/loop* — a snap squashfs was being flagged as a full disk. Supplying only one of --server or --token silently fell back to local-only mode and exited 0, so automation could believe evidence reached the server when it never did. It now fails with a message.
PruneIncidentDiagnostics was generated but never called, so bundles accumulated without bound while the pruner trimmed only check results. Diagnostics now fall under the same retention window. The ingest handler accepted any non-empty JSON, storing null, numbers, strings, and arrays as diagnostic evidence. It now requires a JSON object. Section contents stay unvalidated — the agent owns that schema.
A killed process surfaces as "signal: killed", which does not tell an operator the command hit its time limit — the likeliest failure on the wedged hosts this feature exists to diagnose.
Summary
Adds read-only diagnostic bundles:
pulse-agent --diagnosecollects evidence about why a host failed — OOM kills, disk usage, busiest processes, container and systemd state, Proxmox guest status — and Pulse stores it. The goal is answering "why did this break" on a remote host without opening an SSH session.Collection is on demand only in this PR. Attaching bundles to incidents automatically, and acting on them, are follow-up work.
Type of change
feat— new featurefix— bug fixrefactor— no behaviour changedocsci/build— pipeline or toolingtestchore!/BREAKING CHANGE:footer)Changes
agent
internal/diagnosticspackage: collectors fordmesgOOM kills,dfusage,pstop processes,docker ps -a,systemctl list-units --failed, andqm list.Runner, so they are tested against recorded command output rather than the host the tests run on.dmesg, has no Docker, or is not a Proxmox node still produces a useful bundle, with unavailable collectors recording their own error.exit status 1is useless here;cannot connect to the docker daemonandusage: sudo dmesgare the actual diagnoses.--diagnoseflag for one-shot collection. Prints the bundle with no server configured, which is the only mode available when Pulse itself is unreachable.postJSONextracted fromPushso both ingest paths share one request path.api
incident_diagnostics.incident_idis optional — an on-demand bundle describes a host at a moment, not necessarily an incident — and cascades when set.POST /api/ingest/diagnostics, authenticated with the same agent bearer token as metric ingest.collected_atrecords receipt time, keeping ordering consistent across agents with skewed clocks. The agent's own timestamp survives inside the payload.docs
docs/diagnostics.md, added to the nav;architecture.mdlinks to it.chore
coverage.*artifacts added to.gitignore.Breaking changes
None.
Test plan
cd api && go test ./... -count=1cd agent && go test ./... -count=1cd cli && go test ./... -count=1cd ui && npx tsc --noEmit(UI unchanged)gofmt -lclean,go vet ./...clean in all three modulespulse-agent --diagnoseon macOS.diskanddockercollected;kernel,processes,systemd, andproxmoxdegraded to per-section errors as designed, and the bundle stayed usable.Running it live also caught a bug the unit tests had not:
/devwas flagged as a full disk, because pseudo filesystems permanently report 100% capacity. A false "disk full" would drive a wrong diagnosis, so pseudo filesystems are now listed but never flagged. Covered by a regression test.Known limitations
qm list,systemctl list-units, and thedmesgOOM line should be checked against a live host before relying on them.ps -eo ... --sort=-pcpuis procps-specific. Fine on Debian/Proxmox; it degrades on macOS, so theprocessessection cannot be smoke-tested on a Mac.kernelsection degrades when the agent runs unprivileged.Known gaps, deliberately left for follow-ups
release.ymlcross-compiles onlyapi/cmd/pulse, andinstall.shnever mentions the agent, sopulse-agent --diagnoseis only reachable by building from source. Pre-existing, but this PR is the first change that depends on it.PruneIncidentDiagnosticsexists but nothing calls it;pruner.Runonly prunes check results. The table grows without bound.incident_idreturns 500 and leaks the SQL error rather than a 400.