diff --git a/agent/internal/workloads/control.go b/agent/internal/workloads/control.go new file mode 100644 index 0000000..f4b38d1 --- /dev/null +++ b/agent/internal/workloads/control.go @@ -0,0 +1,105 @@ +package workloads + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + "time" +) + +// ErrProtected is returned for a workload the agent will not act on. +var ErrProtected = errors.New("workload is protected") + +// AgentUnit is the systemd unit this agent runs as. +const AgentUnit = "vantage-agent.service" + +// controlTimeout bounds a stop that may never finish on its own. `docker stop` +// waits on a container that may ignore SIGTERM, and `systemctl stop` on a unit +// with a long TimeoutStopSec blocks for exactly as long as that says. A +// timeout must return a real error rather than an ack implying success. +const controlTimeout = 90 * time.Second + +// ownContainerID is read once: the container this agent runs in, if any. +var ownContainerID = detectOwnContainer() + +var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`) + +// detectOwnContainer returns this process's container ID, or "" on a host +// install. The agent is normally a systemd service, so "" is the common case; +// this exists so containerising it later cannot silently remove the guard. +func detectOwnContainer() string { + b, err := os.ReadFile("/proc/self/cgroup") + if err != nil { + return "" + } + if m := cgroupContainerRe.FindString(string(b)); m != "" { + return m + } + return "" +} + +// isProtected reports whether the agent refuses to act on this workload. +// +// The refusal lives here, in the agent, and not in the control plane. As with +// the console relay hardcoding 127.0.0.1 agent-side: the control plane may name +// a target, but the agent decides what it will do to itself. A server-side +// denylist alone would be bypassed by the next dispatch path someone adds. +func isProtected(kind, id, name string) bool { + if kind == "unit" { + return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service") + } + if ownContainerID == "" { + return false + } + // Container IDs are commonly abbreviated to 12 characters; compare on the + // shorter of the two so a short id still matches a full one. + return strings.HasPrefix(ownContainerID, id) || strings.HasPrefix(id, ownContainerID) +} + +// markProtected stamps the flag onto a collected list so the UI can render the +// action disabled with a reason. +func markProtected(wls []Workload) { + for i := range wls { + wls[i].Protected = isProtected(wls[i].Kind, wls[i].ID, wls[i].Name) + } +} + +// Control starts, stops or restarts a workload. +func Control(ctx context.Context, kind, id, action string) error { + switch action { + case "start", "stop", "restart": + default: + return fmt.Errorf("unknown action %q", action) + } + + // Checked before anything else happens, and checked here rather than only + // on the server. See isProtected. + if isProtected(kind, id, strings.TrimSuffix(id, ".service")) { + return fmt.Errorf("%w: %s", ErrProtected, id) + } + + ctx, cancel := context.WithTimeout(ctx, controlTimeout) + defer cancel() + + var cmd *exec.Cmd + switch kind { + case "container": + cmd = exec.CommandContext(ctx, "docker", action, id) + case "unit": + cmd = exec.CommandContext(ctx, "systemctl", action, id) + default: + return fmt.Errorf("unknown workload kind %q", kind) + } + + if out, err := cmd.CombinedOutput(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout) + } + return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/agent/internal/workloads/workloads.go b/agent/internal/workloads/workloads.go index 850cc89..51975a7 100644 --- a/agent/internal/workloads/workloads.go +++ b/agent/internal/workloads/workloads.go @@ -37,10 +37,6 @@ func Collect(ctx context.Context) Result { 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