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
119 lines
3.1 KiB
Go
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
|
|
}
|