From 0e38d9d500414b67925a1d3161926175fceb92c1 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 7 Aug 2026 08:46:59 +0100 Subject: [PATCH] feat: agent enumerates systemd services --- agent/internal/workloads/systemd.go | 94 +++++++++++++++++++++++++++ agent/internal/workloads/workloads.go | 68 +++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 agent/internal/workloads/systemd.go create mode 100644 agent/internal/workloads/workloads.go diff --git a/agent/internal/workloads/systemd.go b/agent/internal/workloads/systemd.go new file mode 100644 index 0000000..aecd7b0 --- /dev/null +++ b/agent/internal/workloads/systemd.go @@ -0,0 +1,94 @@ +package workloads + +import ( + "context" + "os/exec" + "strings" + "time" +) + +const systemdTimeout = 30 * time.Second + +// excludedPrefixes drops the platform's own units. A typical host carries 300+ +// units and systemd accounts for most of them; listing all of them buries the +// ten anyone cares about. +var excludedPrefixes = []string{"systemd-", "user@", "user-", "session-", "init.scope"} + +// collectSystemd enumerates services in two passes, because "running or +// failed" and "enabled but stopped" are different questions — and an enabled +// unit that is not running is exactly the one worth seeing. +func collectSystemd(ctx context.Context) ([]Workload, bool, string) { + if _, err := exec.LookPath("systemctl"); err != nil { + return nil, false, "" + } + + ctx, cancel := context.WithTimeout(ctx, systemdTimeout) + defer cancel() + + // Column output rather than --output=json: the JSON flag needs systemd + // 246+, and this fleet includes older stable distributions. The columns + // have been stable considerably longer than the JSON has existed. + unitsOut, err := exec.CommandContext(ctx, "systemctl", + "list-units", "--type=service", "--state=running,failed", + "--no-legend", "--plain", "--no-pager").Output() + if err != nil { + return nil, false, "systemctl list-units failed: " + errText(err) + } + + seen := map[string]bool{} + var wls []Workload + + for _, line := range strings.Split(string(unitsOut), "\n") { + f := strings.Fields(line) + // UNIT LOAD ACTIVE SUB DESCRIPTION… + if len(f) < 4 { + continue + } + name := f[0] + if excluded(name) || seen[name] { + continue + } + seen[name] = true + wls = append(wls, Workload{ + Kind: "unit", + ID: name, + Name: strings.TrimSuffix(name, ".service"), + State: f[2], // ACTIVE: active | failed | activating | inactive + }) + } + + filesOut, err := exec.CommandContext(ctx, "systemctl", + "list-unit-files", "--type=service", "--state=enabled", + "--no-legend", "--plain", "--no-pager").Output() + if err == nil { + for _, line := range strings.Split(string(filesOut), "\n") { + f := strings.Fields(line) + // UNIT FILE STATE [PRESET] + if len(f) < 2 { + continue + } + name := f[0] + if excluded(name) || seen[name] { + continue + } + seen[name] = true + wls = append(wls, Workload{ + Kind: "unit", + ID: name, + Name: strings.TrimSuffix(name, ".service"), + State: "inactive", // enabled but not currently running + }) + } + } + + return wls, true, "" +} + +func excluded(name string) bool { + for _, p := range excludedPrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} diff --git a/agent/internal/workloads/workloads.go b/agent/internal/workloads/workloads.go new file mode 100644 index 0000000..850cc89 --- /dev/null +++ b/agent/internal/workloads/workloads.go @@ -0,0 +1,68 @@ +package workloads + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "runtime" + "sort" + "strconv" + "strings" +) + +// Result is one collection pass. +type Result struct { + Workloads []Workload + DockerOK bool + DockerError string + SystemdOK bool + SystemdError string +} + +// Collect enumerates every workload on this host. Linux only. +func Collect(ctx context.Context) Result { + if runtime.GOOS != "linux" { + return Result{} + } + + var r Result + containers, dockerOK, dockerErr := collectDocker(ctx) + units, systemdOK, systemdErr := collectSystemd(ctx) + + r.DockerOK, r.DockerError = dockerOK, dockerErr + r.SystemdOK, r.SystemdError = systemdOK, systemdErr + r.Workloads = append(append([]Workload{}, containers...), units...) + + markProtected(r.Workloads) + return r +} + +// markProtected is a temporary stub; the real implementation lands with the +// control layer in control.go. +func markProtected(_ []Workload) {} + +// Hash fingerprints a workload set so an unchanged set never has to be sent. +// +// It sorts first: `docker ps` output ordering is not stable, and an +// ordering-sensitive hash would resend the full list every 60 seconds forever +// — a cost visible only as traffic. +// +// StartedAt is deliberately excluded: it does not change while a container +// runs, and including it would add nothing. Restarts IS included, because a +// container cycling is exactly the change worth reporting. +func Hash(wls []Workload) string { + lines := make([]string, 0, len(wls)) + for _, w := range wls { + lines = append(lines, strings.Join([]string{ + w.Kind, w.ID, w.Name, w.State, w.Health, w.Image, w.Stack, + strconv.Itoa(w.Restarts), + }, "\x00")) + } + sort.Strings(lines) + h := sha256.New() + for _, l := range lines { + h.Write([]byte(l)) + h.Write([]byte("\n")) + } + return hex.EncodeToString(h.Sum(nil)) +}