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
This commit is contained in:
118
fleet/check/check.go
Normal file
118
fleet/check/check.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user