Files
agent-tools/fleet/check/check.go
claude 184378ae57 fleet: one-shot homelab health snapshot (containers, disks, units)
status across configured hosts via read-only commands (claude-docker
wrapper on the Pi5, local docker/systemctl here). Stable table +
findings list, exit 0/2/1, CI job containers skipped, unreachable
hosts become findings. Parsers unit-tested; smoked against the real
fleet (19 genuine findings). Spec: doc/tool-parity.md 4.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 01:02:06 +02:00

119 lines
3.1 KiB
Go

// Package check parses the health data fleet collects (docker ps,
// df, systemctl --failed) into stable rows and findings an agent can
// diff between runs.
package check
import (
"fmt"
"strconv"
"strings"
)
// Container is one row of docker ps output.
type Container struct {
Name string
State string // running, exited, restarting, ...
Status string // "Up 2 hours (healthy)", "Exited (1) 3 days ago"
Image string
}
// Healthy reports whether the container looks fine: running, and not
// flagged unhealthy.
func (c Container) Healthy() bool {
return c.State == "running" && !strings.Contains(c.Status, "unhealthy")
}
// ParseDockerPS parses `docker ps -a --format
// "{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Image}}"`. CI job
// containers (Gitea Actions workers) are transient and skipped.
func ParseDockerPS(out string) []Container {
var rows []Container
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, "\t")
if len(parts) < 4 {
continue
}
if strings.HasPrefix(parts[0], "GITEA-ACTIONS-TASK-") {
continue
}
rows = append(rows, Container{Name: parts[0], State: parts[1], Status: parts[2], Image: parts[3]})
}
return rows
}
// Mount is one row of df output.
type Mount struct {
Target string
UsedPct int
Avail string
}
// ParseDF parses `df -h --output=target,pcent,avail`.
func ParseDF(out string) []Mount {
var rows []Mount
for i, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if i == 0 || len(fields) < 3 { // header
continue
}
pct, err := strconv.Atoi(strings.TrimSuffix(fields[1], "%"))
if err != nil {
continue
}
rows = append(rows, Mount{Target: fields[0], UsedPct: pct, Avail: fields[2]})
}
return rows
}
// ParseFailedUnits parses `systemctl --failed --no-legend --plain`
// into unit names.
func ParseFailedUnits(out string) []string {
var units []string
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "● "))
if len(fields) == 0 {
continue
}
u := fields[0]
if strings.Contains(u, ".") {
units = append(units, u)
}
}
return units
}
// Finding is one thing worth attention.
type Finding struct {
Host string
Text string
}
func (f Finding) String() string { return f.Host + ": " + f.Text }
// Evaluate turns parsed data into findings. diskWarnPct is the fill
// grade that counts as a problem.
func Evaluate(host string, containers []Container, mounts []Mount, failedSys, failedUser []string, diskWarnPct int) []Finding {
var out []Finding
for _, c := range containers {
if !c.Healthy() {
out = append(out, Finding{host, fmt.Sprintf("container %s: %s (%s)", c.Name, c.State, c.Status)})
}
}
for _, m := range mounts {
if m.UsedPct >= diskWarnPct {
out = append(out, Finding{host, fmt.Sprintf("disk %s at %d%% (%s left)", m.Target, m.UsedPct, m.Avail)})
}
}
for _, u := range failedSys {
out = append(out, Finding{host, "failed unit: " + u})
}
for _, u := range failedUser {
out = append(out, Finding{host, "failed user unit: " + u})
}
return out
}