From 3511c34daa473ec7370883df8b77fcfcebf84169 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 7 Aug 2026 08:46:03 +0100 Subject: [PATCH] feat: agent enumerates docker containers --- agent/internal/workloads/docker.go | 141 +++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 agent/internal/workloads/docker.go diff --git a/agent/internal/workloads/docker.go b/agent/internal/workloads/docker.go new file mode 100644 index 0000000..8584154 --- /dev/null +++ b/agent/internal/workloads/docker.go @@ -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() +}