feat: agent enumerates docker containers

This commit is contained in:
2026-08-07 08:46:03 +01:00
parent 0838d1d735
commit 3511c34daa
+141
View File
@@ -0,0 +1,141 @@
package workloads
import (
"context"
"encoding/json"
"os/exec"
"sort"
"strings"
"time"
)
// Workload is one container or one systemd unit, agent-side. It mirrors
// models.Workload on the server.
type Workload struct {
Kind string
ID string
Name string
State string
Health string
Image string
Stack string
Ports []string
Restarts int
StartedAt time.Time
Protected bool
}
const dockerTimeout = 30 * time.Second
// dockerInspect is the subset of `docker inspect` output we read.
//
// We use inspect rather than `docker ps --format '{{json .}}'` because ps
// reports health and uptime inside a human Status string — "Up 2 hours
// (healthy)" — and anything built on that is parsing English that is
// localised, reworded between releases, and silently different for a paused or
// restarting container. inspect gives typed fields instead.
type dockerInspect struct {
ID string `json:"Id"`
Name string `json:"Name"`
State struct {
Status string `json:"Status"`
StartedAt string `json:"StartedAt"`
Restarting bool `json:"Restarting"`
Health *struct {
Status string `json:"Status"`
} `json:"Health"`
} `json:"State"`
Config struct {
Image string `json:"Image"`
Labels map[string]string `json:"Labels"`
} `json:"Config"`
RestartCount int `json:"RestartCount"`
NetworkSettings struct {
Ports map[string][]struct {
HostIP string `json:"HostIp"`
HostPort string `json:"HostPort"`
} `json:"Ports"`
} `json:"NetworkSettings"`
}
// collectDocker enumerates containers. It returns ok=false with an empty error
// string when Docker is simply not installed — the common case on this fleet,
// and not a fault.
func collectDocker(ctx context.Context) ([]Workload, bool, string) {
if _, err := exec.LookPath("docker"); err != nil {
return nil, false, "" // not installed; not an error
}
ctx, cancel := context.WithTimeout(ctx, dockerTimeout)
defer cancel()
idsOut, err := exec.CommandContext(ctx, "docker", "ps", "-aq").Output()
if err != nil {
// Installed but not answering: a different problem with a different
// fix, so it carries a message where "not installed" does not.
return nil, false, "docker ps failed: " + errText(err)
}
ids := strings.Fields(string(idsOut))
if len(ids) == 0 {
return []Workload{}, true, "" // Docker present, nothing running
}
args := append([]string{"inspect", "--format", "{{json .}}"}, ids...)
out, err := exec.CommandContext(ctx, "docker", args...).Output()
if err != nil {
return nil, false, "docker inspect failed: " + errText(err)
}
var wls []Workload
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var di dockerInspect
if err := json.Unmarshal([]byte(line), &di); err != nil {
continue
}
wls = append(wls, dockerToWorkload(di))
}
return wls, true, ""
}
func dockerToWorkload(di dockerInspect) Workload {
w := Workload{
Kind: "container",
ID: di.ID,
Name: strings.TrimPrefix(di.Name, "/"),
State: di.State.Status,
Image: di.Config.Image,
Restarts: di.RestartCount,
}
if di.State.Health != nil {
w.Health = strings.ToLower(di.State.Health.Status)
}
// The compose project label is what Docker itself treats as authoritative.
// No YAML is read from disk: a compose file there may not be what is running.
if v := di.Config.Labels["com.docker.compose.project"]; v != "" {
w.Stack = v
}
if t, err := time.Parse(time.RFC3339Nano, di.State.StartedAt); err == nil {
w.StartedAt = t
}
for container, bindings := range di.NetworkSettings.Ports {
for _, b := range bindings {
w.Ports = append(w.Ports, b.HostIP+":"+b.HostPort+"->"+container)
}
}
// Map iteration order is random; sort so a stored snapshot does not reorder
// its own ports between two otherwise identical reports.
sort.Strings(w.Ports)
return w
}
func errText(err error) string {
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return strings.TrimSpace(string(ee.Stderr))
}
return err.Error()
}