carlos / platform Public

instancectl: the root control service, peer-authenticated, with the five verbs and the probe loop (plan 2, task 5)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
Paul Campbell pushed by paul@keymail.dev 82caa6922f30901f41835fa38c5d636e62dbc278 parent acaa97c
4 files changed, +947 −0
  • internal/instancectl/peer_linux.go +157 −0
  • internal/instancectl/peer_other.go +15 −0
  • internal/instancectl/server.go +477 −0
  • internal/instancectl/server_test.go +298 −0
diff --git a/internal/instancectl/peer_linux.go b/internal/instancectl/peer_linux.go
new file mode 100644
index 000000000..c36f15059
--- /dev/null
+++ b/internal/instancectl/peer_linux.go
@@ -0,0 +1,157 @@
+//go:build linux
+
+package instancectl
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
+ "os/user"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+// peerCred reads SO_PEERCRED from a connected unix socket.
+func peerCred(conn net.Conn) (*unix.Ucred, error) {
+ uc, ok := conn.(*net.UnixConn)
+ if !ok {
+ return nil, errors.New("not a unix connection")
+ }
+ raw, err := uc.SyscallConn()
+ if err != nil {
+ return nil, err
+ }
+ var cred *unix.Ucred
+ var cerr error
+ if err := raw.Control(func(fd uintptr) {
+ cred, cerr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
+ }); err != nil {
+ return nil, err
+ }
+ return cred, cerr
+}
+
+// productionPeerOK requires the peer to be uid carlos AND the main process
+// of carlos-edge.service as systemd reports it (design §5.4): a flat tenant
+// is a child of that pid, never the pid itself, and a nested tenant cannot
+// reach the socket at all. MainPID is asked of systemctl and cached for one
+// second, because the agent restarts and the pid changes.
+func productionPeerOK() func(net.Conn) error {
+ u, err := user.Lookup("carlos")
+ if err != nil {
+ return func(net.Conn) error { return fmt.Errorf("no carlos user: %v", err) }
+ }
+ carlosUID, _ := strconv.Atoi(u.Uid)
+ var mu sync.Mutex
+ var mainPID int
+ var at time.Time
+ edgePID := func() (int, error) {
+ mu.Lock()
+ defer mu.Unlock()
+ if time.Since(at) < time.Second && mainPID > 0 {
+ return mainPID, nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, "systemctl", "show", "-p", "MainPID", "--value", edgeUnit).Output()
+ if err != nil {
+ return 0, err
+ }
+ pid, err := strconv.Atoi(strings.TrimSpace(string(out)))
+ if err != nil || pid <= 0 {
+ return 0, fmt.Errorf("carlos-edge.service has no MainPID")
+ }
+ mainPID, at = pid, time.Now()
+ return pid, nil
+ }
+ return func(conn net.Conn) error {
+ cred, err := peerCred(conn)
+ if err != nil {
+ return err
+ }
+ if int(cred.Uid) != carlosUID {
+ return fmt.Errorf("peer uid %d is not carlos", cred.Uid)
+ }
+ pid, err := edgePID()
+ if err != nil {
+ return err
+ }
+ if int(cred.Pid) != pid {
+ return fmt.Errorf("peer pid %d is not the edge's main pid %d", cred.Pid, pid)
+ }
+ return nil
+ }
+}
+
+// activatedListener returns the socket-activated listener when systemd
+// passed one (LISTEN_PID is us, LISTEN_FDS is 1, the fd is 3).
+func activatedListener() (net.Listener, bool, error) {
+ if os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
+ return nil, false, nil
+ }
+ if os.Getenv("LISTEN_FDS") != "1" {
+ return nil, false, fmt.Errorf("LISTEN_FDS=%q, want 1", os.Getenv("LISTEN_FDS"))
+ }
+ f := os.NewFile(3, "control.sock")
+ defer f.Close()
+ ln, err := net.FileListener(f)
+ if err != nil {
+ return nil, false, err
+ }
+ return ln, true, nil
+}
+
+// bindControlSocket is the hand-run fallback: bind the path with the
+// ownership the socket unit would give it (carlos, 0600).
+func bindControlSocket(path string) (net.Listener, error) {
+ _ = os.Remove(path)
+ ln, err := net.Listen("unix", path)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.Lookup("carlos")
+ if err != nil {
+ ln.Close()
+ return nil, err
+ }
+ uid, _ := strconv.Atoi(u.Uid)
+ gid, _ := strconv.Atoi(u.Gid)
+ if err := os.Chown(path, uid, gid); err != nil {
+ ln.Close()
+ return nil, err
+ }
+ if err := os.Chmod(path, 0o600); err != nil {
+ ln.Close()
+ return nil, err
+ }
+ return ln, nil
+}
+
+// fixtureIsRootCarlos0640 checks the probe fixture tmpfiles stages.
+func fixtureIsRootCarlos0640() error {
+ fi, err := os.Stat("/etc/carlos/probe-secret")
+ if err != nil {
+ return fmt.Errorf("probe fixture absent: %v", err)
+ }
+ st, ok := fi.Sys().(*syscall.Stat_t)
+ if !ok {
+ return errors.New("probe fixture: no stat")
+ }
+ g, err := user.LookupGroup("carlos")
+ if err != nil {
+ return err
+ }
+ gid, _ := strconv.Atoi(g.Gid)
+ if st.Uid != 0 || int(st.Gid) != gid || fi.Mode().Perm() != 0o640 {
+ return fmt.Errorf("probe fixture is uid %d gid %d mode %o, want root:carlos 0640", st.Uid, st.Gid, fi.Mode().Perm())
+ }
+ return nil
+}
diff --git a/internal/instancectl/peer_other.go b/internal/instancectl/peer_other.go
new file mode 100644
index 000000000..30cc02d32
--- /dev/null
+++ b/internal/instancectl/peer_other.go
@@ -0,0 +1,15 @@
+//go:build !linux
+
+package instancectl
+
+import (
+ "errors"
+ "net"
+)
+
+func productionPeerOK() func(net.Conn) error {
+ return func(net.Conn) error { return errors.ErrUnsupported }
+}
+func activatedListener() (net.Listener, bool, error) { return nil, false, errors.ErrUnsupported }
+func bindControlSocket(string) (net.Listener, error) { return nil, errors.ErrUnsupported }
+func fixtureIsRootCarlos0640() error { return errors.ErrUnsupported }
diff --git a/internal/instancectl/server.go b/internal/instancectl/server.go
new file mode 100644
index 000000000..5995fc057
--- /dev/null
+++ b/internal/instancectl/server.go
@@ -0,0 +1,477 @@
+package instancectl
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/cliflag"
+ "github.com/carlosframework/platform/internal/controlsock"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// Server is the root instance-control service (design §5). It does four
+// things and nothing else: starts, stops and sweeps the three templates'
+// instances, sets a host slice's memory tier before a start, runs the
+// readiness probe, and answers /ready. Every systemctl argument it passes
+// is built from a validated host by UnitName/SliceName; nothing from a
+// request reaches systemctl as a string.
+type Server struct {
+ // Systemctl runs one systemctl invocation and returns its combined
+ // output; production is exec.Command, tests record.
+ Systemctl func(ctx context.Context, args ...string) (string, error)
+ // PeerOK is asked once per accepted connection, before a byte is read.
+ // Production requires SO_PEERCRED uid carlos and pid == MainPID of
+ // carlos-edge.service (design §5.4). A refused connection is closed
+ // with no response.
+ PeerOK func(conn net.Conn) error
+ // CgroupRoot is /sys/fs/cgroup; tests point it at a fixture tree.
+ CgroupRoot string
+ // ProbeOut is where the probe unit's StandardOutput=truncate: lands.
+ ProbeOut string
+ // FixtureOK verifies /etc/carlos/probe-secret is root:carlos 0640
+ // before a probe verdict is accepted (design §8.5, codex R11-1).
+ FixtureOK func() error
+ // ProbeEvery is the re-run cadence (five minutes in production).
+ ProbeEvery time.Duration
+ Logf func(string, ...any)
+
+ // cgroupWaitBudget bounds a wait for an empty cgroup; tests shorten it.
+ cgroupWaitBudget time.Duration
+
+ mu sync.Mutex
+ report ProbeReport
+ probed bool
+}
+
+const (
+ probeUnit = "carlos-instance-probe.service"
+ edgeUnit = "carlos-edge.service"
+ parentSlices = "carlos.slice/carlos-instances.slice"
+)
+
+// Run is `carlos instance-control`.
+func Run(args []string) {
+ fs := flag.NewFlagSet("instance-control", flag.ExitOnError)
+ fs.Usage = cliflag.Usage(fs, "usage: carlos instance-control (socket-activated by carlos-instance-control.socket)")
+ _ = fs.Parse(args)
+ if os.Geteuid() != 0 {
+ log.Fatal("instance-control: this is the root service behind carlos-instance-control.socket; it is not for operators")
+ }
+ s := &Server{
+ Systemctl: func(ctx context.Context, args ...string) (string, error) {
+ out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput()
+ return string(out), err
+ },
+ PeerOK: productionPeerOK(),
+ CgroupRoot: "/sys/fs/cgroup",
+ ProbeOut: filepath.Join(controlsock.Dir, "probe.out"),
+ FixtureOK: fixtureIsRootCarlos0640,
+ ProbeEvery: 5 * time.Minute,
+ Logf: log.Printf,
+ }
+ ln, activated, err := activatedListener()
+ if err != nil {
+ log.Fatalf("instance-control: %v", err)
+ }
+ if !activated {
+ // Not socket-activated (a hand run): bind the path ourselves with
+ // the same ownership the socket unit gives it.
+ ln, err = bindControlSocket(controlsock.Path)
+ if err != nil {
+ log.Fatalf("instance-control: %v", err)
+ }
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go s.probeLoop(ctx)
+ if err := s.Serve(ctx, ln); err != nil {
+ log.Fatalf("instance-control: %v", err)
+ }
+}
+
+// Serve accepts connections, asks PeerOK about each, and hands the accepted
+// ones to the HTTP server one at a time through a listener that only ever
+// yields vetted connections.
+func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
+ vetted := &vettedListener{Listener: ln, ok: s.PeerOK, logf: s.logf}
+ srv := &http.Server{Handler: s.Handler(), ReadHeaderTimeout: 5 * time.Second}
+ go func() {
+ <-ctx.Done()
+ srv.Close()
+ }()
+ err := srv.Serve(vetted)
+ if errors.Is(err, http.ErrServerClosed) || ctx.Err() != nil {
+ return nil
+ }
+ return err
+}
+
+// vettedListener runs the peer check inside Accept so a refused peer is
+// closed before net/http ever reads from it.
+type vettedListener struct {
+ net.Listener
+ ok func(net.Conn) error
+ logf func(string, ...any)
+}
+
+func (v *vettedListener) Accept() (net.Conn, error) {
+ for {
+ c, err := v.Listener.Accept()
+ if err != nil {
+ return nil, err
+ }
+ if err := v.ok(c); err != nil {
+ v.logf("instance-control: refused connection: %v", err)
+ c.Close()
+ continue
+ }
+ return c, nil
+ }
+}
+
+func (s *Server) logf(format string, args ...any) {
+ if s.Logf != nil {
+ s.Logf(format, args...)
+ }
+}
+
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /start", s.handleStart)
+ mux.HandleFunc("POST /seed", s.handleSeed)
+ mux.HandleFunc("POST /stop", s.handleStop)
+ mux.HandleFunc("POST /reset", s.handleReset)
+ mux.HandleFunc("POST /sweep", s.handleSweep)
+ mux.HandleFunc("GET /ready", s.handleReady)
+ return mux
+}
+
+// request is the validated form of a verb's query: nothing else reaches a
+// unit name.
+type request struct {
+ host string
+ role Role
+ tier cgroup.Limit
+}
+
+func parseRequest(r *http.Request, roles []Role, wantTier bool) (request, error) {
+ q := r.URL.Query()
+ host := q.Get("host")
+ if !provision.ValidHostShape(host) {
+ return request{}, fmt.Errorf("host %q is not a host", host)
+ }
+ req := request{host: host}
+ if len(roles) > 0 {
+ role, ok := ParseRole(q.Get("role"))
+ if !ok {
+ return request{}, fmt.Errorf("role %q is not run, seed or sidecar", q.Get("role"))
+ }
+ allowed := false
+ for _, r := range roles {
+ allowed = allowed || r == role
+ }
+ if !allowed {
+ return request{}, fmt.Errorf("role %s is not valid for this verb", role)
+ }
+ req.role = role
+ }
+ if wantTier {
+ n, err := strconv.ParseInt(q.Get("memory_max"), 10, 64)
+ if err != nil {
+ return request{}, fmt.Errorf("memory_max %q is not a number", q.Get("memory_max"))
+ }
+ switch n {
+ case cgroup.TierDefault.MemoryMax, cgroup.TierChildren.MemoryMax:
+ req.tier = cgroup.Limit{MemoryMax: n}
+ default:
+ return request{}, fmt.Errorf("memory_max %d is not a platform tier", n)
+ }
+ }
+ return req, nil
+}
+
+func (s *Server) setTier(ctx context.Context, host string, tier cgroup.Limit) error {
+ max := strconv.FormatInt(tier.MemoryMax, 10)
+ // Multiply before dividing: MemoryMax/10*9 truncates a step early and
+ // mis-rounds by up to a few bytes against the tier constants' expected
+ // MemoryHigh (536870912 -> 483183820, not 483183819).
+ high := strconv.FormatInt(tier.MemoryMax*9/10, 10)
+ _, err := s.Systemctl(ctx, "set-property", "--runtime", SliceName(host),
+ "MemoryMax="+max, "MemoryHigh="+high, "MemorySwapMax="+max)
+ return err
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
+ req, err := parseRequest(r, []Role{RoleRun, RoleSidecar}, true)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ ctx := r.Context()
+ unit := UnitName(req.role, req.host)
+ if err := s.setTier(ctx, req.host, req.tier); err != nil {
+ http.Error(w, "set-property: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ if out, err := s.Systemctl(ctx, "start", unit); err != nil {
+ http.Error(w, "start: "+strings.TrimSpace(out+" "+err.Error()), http.StatusBadGateway)
+ return
+ }
+ inv, err := s.Systemctl(ctx, "show", "-p", "InvocationID", "--value", unit)
+ if err != nil {
+ http.Error(w, "show: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ s.logf("instance-control: %s: started %s memory.max=%d", req.host, unit, req.tier.MemoryMax)
+ writeJSON(w, http.StatusOK, StartResponse{InvocationID: strings.TrimSpace(inv)})
+}
+
+// parseShow reads systemctl show's KEY=value lines.
+func parseShow(out string) map[string]string {
+ m := map[string]string{}
+ sc := bufio.NewScanner(strings.NewReader(out))
+ for sc.Scan() {
+ if k, v, ok := strings.Cut(sc.Text(), "="); ok {
+ m[k] = strings.TrimSpace(v)
+ }
+ }
+ return m
+}
+
+func (s *Server) handleSeed(w http.ResponseWriter, r *http.Request) {
+ req, err := parseRequest(r, nil, true)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ ctx := r.Context()
+ unit := UnitName(RoleSeed, req.host)
+ if err := s.setTier(ctx, req.host, req.tier); err != nil {
+ http.Error(w, "set-property: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ // A oneshot's start job completes when ExecStart exits; RemainAfterExit
+ // keeps the unit loaded with its result until the stop below.
+ startOut, startErr := s.Systemctl(ctx, "start", unit)
+ out, err := s.Systemctl(ctx, "show", "-p", "InvocationID,Result,ExecMainStatus,ExecMainCode", unit)
+ if err != nil {
+ http.Error(w, "show: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ m := parseShow(out)
+ wctx, cancel := context.WithTimeout(ctx, s.waitBudget())
+ defer cancel()
+ if err := WaitCgroupEmpty(wctx, s.CgroupRoot, SliceName(req.host), unit, 200*time.Millisecond); err != nil {
+ http.Error(w, "seed: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ if _, err := s.Systemctl(ctx, "stop", unit); err != nil {
+ s.logf("instance-control: %s: stop after seed: %v", req.host, err)
+ }
+ if startErr != nil && m["Result"] == "" {
+ http.Error(w, "start: "+strings.TrimSpace(startOut+" "+startErr.Error()), http.StatusBadGateway)
+ return
+ }
+ writeJSON(w, http.StatusOK, SeedResponse{InvocationID: m["InvocationID"], Result: m["Result"], Status: m["ExecMainStatus"], Code: m["ExecMainCode"]})
+}
+
+func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
+ req, err := parseRequest(r, []Role{RoleRun, RoleSeed, RoleSidecar}, false)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if out, err := s.Systemctl(r.Context(), "stop", UnitName(req.role, req.host)); err != nil {
+ http.Error(w, "stop: "+strings.TrimSpace(out+" "+err.Error()), http.StatusBadGateway)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
+ req, err := parseRequest(r, []Role{RoleRun, RoleSeed, RoleSidecar}, false)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if out, err := s.Systemctl(r.Context(), "reset-failed", UnitName(req.role, req.host)); err != nil && !strings.Contains(out+err.Error(), "not loaded") {
+ http.Error(w, "reset-failed: "+strings.TrimSpace(out+" "+err.Error()), http.StatusBadGateway)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// handleSweep stops every instance of the three templates that is not
+// inactive or has a job, keyed on the names systemd reports, so a unit
+// whose identifier is hashed and whose row is gone is still stopped
+// (design §6.7, codex R7-1).
+func (s *Server) handleSweep(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ targets := map[string]bool{}
+ out, err := s.Systemctl(ctx, "list-units", "--all", "--plain", "--no-legend", "--no-pager",
+ UnitPrefixRun+"*.service", UnitPrefixSeed+"*.service", UnitPrefixSidecar+"*.service")
+ if err != nil {
+ http.Error(w, "list-units: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ for _, line := range strings.Split(out, "\n") {
+ f := strings.Fields(line)
+ if len(f) < 3 {
+ continue
+ }
+ if _, _, ok := ParseUnitName(f[0]); !ok {
+ continue
+ }
+ if f[2] != "inactive" {
+ targets[f[0]] = true
+ }
+ }
+ jobs, err := s.Systemctl(ctx, "list-jobs", "--plain", "--no-legend", "--no-pager")
+ if err != nil {
+ http.Error(w, "list-jobs: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ for _, line := range strings.Split(jobs, "\n") {
+ f := strings.Fields(line)
+ if len(f) < 2 {
+ continue
+ }
+ if _, _, ok := ParseUnitName(f[1]); ok {
+ targets[f[1]] = true
+ }
+ }
+ var stopped []string
+ for unit := range targets {
+ if _, err := s.Systemctl(ctx, "stop", unit); err != nil {
+ s.logf("instance-control: sweep: stop %s: %v", unit, err)
+ }
+ _, esc, _ := ParseUnitName(unit)
+ wctx, cancel := context.WithTimeout(ctx, s.waitBudget())
+ err := WaitCgroupEmpty(wctx, s.CgroupRoot, "carlos-instances-"+esc+".slice", unit, 200*time.Millisecond)
+ cancel()
+ if err != nil {
+ http.Error(w, "sweep: "+unit+": "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ stopped = append(stopped, unit)
+ }
+ writeJSON(w, http.StatusOK, SweepResponse{Units: stopped})
+}
+
+func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
+ rep, ready := s.Ready()
+ if ready {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ writeJSON(w, http.StatusServiceUnavailable, rep)
+}
+
+// Ready returns the last probe verdict. Before the first probe, or when
+// the last one could not be read, the box is not ready.
+func (s *Server) Ready() (ProbeReport, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.report, s.probed && s.report.Ready
+}
+
+func (s *Server) waitBudget() time.Duration {
+ if s.cgroupWaitBudget > 0 {
+ return s.cgroupWaitBudget
+ }
+ return 30 * time.Second
+}
+
+// WaitCgroupEmpty waits until the unit's cgroup under the host slice is
+// absent or reports "populated 0" in cgroup.events, polling every `every`,
+// bounded by ctx and the caller's budget (design §6.5, codex R2-5).
+func WaitCgroupEmpty(ctx context.Context, root, slice, unit string, every time.Duration) error {
+ events := filepath.Join(root, parentSlices, slice, unit, "cgroup.events")
+ for {
+ b, err := os.ReadFile(events)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err == nil && strings.Contains(string(b), "populated 0") {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("processes remain in %s", unit)
+ case <-time.After(every):
+ }
+ }
+}
+
+// probeLoop runs the probe at start and every ProbeEvery.
+func (s *Server) probeLoop(ctx context.Context) {
+ s.RunProbe(ctx)
+ t := time.NewTicker(s.ProbeEvery)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ s.RunProbe(ctx)
+ }
+ }
+}
+
+// RunProbe starts the probe unit (a oneshot: start blocks until it exits),
+// reads the report it left, and stores the verdict. A fixture that is not
+// root:carlos 0640, a probe that could not be started, or a report that
+// could not be read all leave the box not ready, with the reason on a
+// verdict line; readiness is never the default.
+func (s *Server) RunProbe(ctx context.Context) {
+ set := func(r ProbeReport) {
+ s.mu.Lock()
+ s.report, s.probed = r, true
+ s.mu.Unlock()
+ }
+ if err := s.FixtureOK(); err != nil {
+ set(ProbeReport{Lines: []VerdictLine{{Check: "fixture", Verdict: "fail", Detail: err.Error()}}})
+ return
+ }
+ if out, err := s.Systemctl(ctx, "start", probeUnit); err != nil {
+ set(ProbeReport{Lines: []VerdictLine{{Check: "probe-unit", Verdict: "fail", Detail: strings.TrimSpace(out + " " + err.Error())}}})
+ return
+ }
+ b, err := os.ReadFile(s.ProbeOut)
+ if err != nil {
+ set(ProbeReport{Lines: []VerdictLine{{Check: "probe-output", Verdict: "fail", Detail: err.Error()}}})
+ return
+ }
+ var r ProbeReport
+ if err := json.Unmarshal(b, &r); err != nil {
+ set(ProbeReport{Lines: []VerdictLine{{Check: "probe-output", Verdict: "fail", Detail: err.Error()}}})
+ return
+ }
+ for _, l := range r.Lines {
+ if l.Verdict != "ok" {
+ s.logf("instance-control: probe: %s %s %s", l.Check, l.Verdict, l.Detail)
+ }
+ }
+ set(r)
+}
diff --git a/internal/instancectl/server_test.go b/internal/instancectl/server_test.go
new file mode 100644
index 000000000..d686645ff
--- /dev/null
+++ b/internal/instancectl/server_test.go
@@ -0,0 +1,298 @@
+package instancectl
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+type fakeCtl struct {
+ mu sync.Mutex
+ calls []string
+ // answers maps a leading argument pattern ("show -p InvocationID") to output.
+ answers map[string]string
+ fail map[string]error
+}
+
+func (f *fakeCtl) run(_ context.Context, args ...string) (string, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ line := strings.Join(args, " ")
+ f.calls = append(f.calls, line)
+ for k, err := range f.fail {
+ if strings.HasPrefix(line, k) {
+ return "", err
+ }
+ }
+ for k, out := range f.answers {
+ if strings.HasPrefix(line, k) {
+ return out, nil
+ }
+ }
+ return "", nil
+}
+
+func (f *fakeCtl) joined() string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return strings.Join(f.calls, "\n")
+}
+
+func newServer(t *testing.T, ctl *fakeCtl) (*Server, *http.Client) {
+ t.Helper()
+ root := t.TempDir()
+ s := &Server{
+ Systemctl: ctl.run,
+ PeerOK: func(net.Conn) error { return nil },
+ CgroupRoot: filepath.Join(root, "cgroup"),
+ ProbeOut: filepath.Join(root, "probe.out"),
+ FixtureOK: func() error { return nil },
+ ProbeEvery: time.Hour,
+ Logf: t.Logf,
+ }
+ dir, err := os.MkdirTemp("", "ic")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ sock := filepath.Join(dir, "c.sock")
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Skipf("cannot bind a unix socket here (%v); run with TMPDIR=/tmp/g outside the sandbox", err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ go s.Serve(ctx, ln) //nolint:errcheck
+ t.Cleanup(func() { ln.Close() })
+ client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", sock)
+ }}}
+ return s, client
+}
+
+func post(t *testing.T, c *http.Client, path string) *http.Response {
+ t.Helper()
+ resp, err := c.Post("http://unix"+path, "", nil)
+ if err != nil {
+ t.Fatalf("POST %s: %v", path, err)
+ }
+ return resp
+}
+
+func TestStartSetsTheTierThenStartsThenReadsTheInvocation(t *testing.T) {
+ ctl := &fakeCtl{answers: map[string]string{"show -p InvocationID --value": "abc123\n"}}
+ _, c := newServer(t, ctl)
+ resp := post(t, c, "/start?host=bill-2.example.com&role=run&memory_max=536870912")
+ if resp.StatusCode != 200 {
+ t.Fatalf("status %d", resp.StatusCode)
+ }
+ var out StartResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.InvocationID != "abc123" {
+ t.Errorf("response = %+v, %v", out, err)
+ }
+ want := "set-property --runtime carlos-instances-bill\\x2d2.example.com.slice MemoryMax=536870912 MemoryHigh=483183820 MemorySwapMax=536870912\n" +
+ "start carlos-instance@bill\\x2d2.example.com.service\n" +
+ "show -p InvocationID --value carlos-instance@bill\\x2d2.example.com.service"
+ if got := ctl.joined(); got != want {
+ t.Errorf("systemctl calls:\n%s\nwant:\n%s", got, want)
+ }
+}
+
+func TestStartRefusesBadInputsWithoutCallingSystemctl(t *testing.T) {
+ ctl := &fakeCtl{}
+ _, c := newServer(t, ctl)
+ for _, q := range []string{
+ "host=Bad_Host&role=run&memory_max=536870912",
+ "host=h.example.com&role=exec&memory_max=536870912",
+ "host=h.example.com&role=run&memory_max=1",
+ "host=h.example.com&role=seed&memory_max=536870912", // seed is not a /start role
+ "role=run&memory_max=536870912",
+ } {
+ resp := post(t, c, "/start?"+q)
+ if resp.StatusCode != 400 {
+ t.Errorf("%s: status %d, want 400", q, resp.StatusCode)
+ }
+ }
+ if ctl.joined() != "" {
+ t.Errorf("systemctl was called: %s", ctl.joined())
+ }
+}
+
+func TestStartReportsSystemctlFailureAs502(t *testing.T) {
+ ctl := &fakeCtl{fail: map[string]error{"start ": errors.New("Job for carlos-instance@h.example.com.service failed")}}
+ _, c := newServer(t, ctl)
+ resp := post(t, c, "/start?host=h.example.com&role=sidecar&memory_max=1073741824")
+ if resp.StatusCode != 502 {
+ t.Errorf("status %d, want 502", resp.StatusCode)
+ }
+ if !strings.Contains(ctl.joined(), "MemoryMax=1073741824 MemoryHigh=966367641 MemorySwapMax=1073741824") {
+ t.Errorf("children tier not applied: %s", ctl.joined())
+ }
+}
+
+func TestSeedBlocksReadsTheRetainedResultWaitsAndStops(t *testing.T) {
+ ctl := &fakeCtl{answers: map[string]string{
+ "show -p InvocationID,Result,ExecMainStatus,ExecMainCode": "InvocationID=feed\nResult=success\nExecMainStatus=0\nExecMainCode=exited\n",
+ }}
+ s, c := newServer(t, ctl)
+ // An empty cgroup: cgroup.events says populated 0.
+ ev := filepath.Join(s.CgroupRoot, "carlos.slice", "carlos-instances.slice", "carlos-instances-h.example.com.slice", "carlos-instance-seed@h.example.com.service", "cgroup.events")
+ if err := os.MkdirAll(filepath.Dir(ev), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(ev, []byte("populated 0\nfrozen 0\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ resp := post(t, c, "/seed?host=h.example.com&memory_max=536870912")
+ if resp.StatusCode != 200 {
+ t.Fatalf("status %d", resp.StatusCode)
+ }
+ var out SeedResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.InvocationID != "feed" || out.Result != "success" || out.Status != "0" || out.Code != "exited" {
+ t.Errorf("response = %+v, %v", out, err)
+ }
+ calls := ctl.joined()
+ for _, want := range []string{"set-property --runtime carlos-instances-h.example.com.slice", "start carlos-instance-seed@h.example.com.service", "show -p InvocationID,Result,ExecMainStatus,ExecMainCode carlos-instance-seed@h.example.com.service", "stop carlos-instance-seed@h.example.com.service"} {
+ if !strings.Contains(calls, want) {
+ t.Errorf("missing %q in\n%s", want, calls)
+ }
+ }
+ if strings.Index(calls, "show -p") > strings.Index(calls, "stop ") {
+ t.Errorf("stop before show:\n%s", calls)
+ }
+}
+
+func TestSeedFailsWhenTheCgroupStaysPopulated(t *testing.T) {
+ ctl := &fakeCtl{answers: map[string]string{"show -p InvocationID,Result": "InvocationID=feed\nResult=success\nExecMainStatus=0\nExecMainCode=exited\n"}}
+ s, c := newServer(t, ctl)
+ s.cgroupWaitBudget = 200 * time.Millisecond
+ ev := filepath.Join(s.CgroupRoot, "carlos.slice", "carlos-instances.slice", "carlos-instances-h.example.com.slice", "carlos-instance-seed@h.example.com.service", "cgroup.events")
+ if err := os.MkdirAll(filepath.Dir(ev), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(ev, []byte("populated 1\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ resp := post(t, c, "/seed?host=h.example.com&memory_max=536870912")
+ if resp.StatusCode != 502 {
+ t.Errorf("status %d, want 502 while processes remain", resp.StatusCode)
+ }
+}
+
+func TestStopResetAndTheirTolerances(t *testing.T) {
+ ctl := &fakeCtl{fail: map[string]error{"reset-failed carlos-instance-sidecar@": errors.New("Unit carlos-instance-sidecar@h.example.com.service not loaded.")}}
+ _, c := newServer(t, ctl)
+ if resp := post(t, c, "/stop?host=h.example.com&role=seed"); resp.StatusCode != 204 {
+ t.Errorf("stop status %d", resp.StatusCode)
+ }
+ if resp := post(t, c, "/reset?host=h.example.com&role=sidecar"); resp.StatusCode != 204 {
+ t.Errorf("reset of an unloaded unit: status %d, want 204 (tolerated)", resp.StatusCode)
+ }
+ if resp := post(t, c, "/reset?host=h.example.com&role=nope"); resp.StatusCode != 400 {
+ t.Errorf("bad role: status %d", resp.StatusCode)
+ }
+ if !strings.Contains(ctl.joined(), "stop carlos-instance-seed@h.example.com.service") {
+ t.Errorf("stop not issued: %s", ctl.joined())
+ }
+}
+
+func TestSweepStopsEveryInstanceUnitAndJob(t *testing.T) {
+ ctl := &fakeCtl{answers: map[string]string{
+ "list-units": "carlos-instance@a.example.com.service loaded active running carlos exec-backed instance a.example.com\n" +
+ "carlos-instance-sidecar@a.example.com.service loaded inactive dead ...\n" +
+ "carlos-instance-seed@_aaaa\\x2d0123456789abcdef.service loaded activating start ...\n",
+ "list-jobs": "12 carlos-instance@b.example.com.service start waiting\n",
+ "show -p ActiveState --value": "inactive\n",
+ }}
+ s, c := newServer(t, ctl)
+ s.cgroupWaitBudget = time.Second
+ resp := post(t, c, "/sweep")
+ if resp.StatusCode != 200 {
+ t.Fatalf("status %d", resp.StatusCode)
+ }
+ var out SweepResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatal(err)
+ }
+ got := strings.Join(out.Units, " ")
+ for _, want := range []string{"carlos-instance@a.example.com.service", "carlos-instance-seed@_aaaa\\x2d0123456789abcdef.service", "carlos-instance@b.example.com.service"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("sweep did not stop %s: %v", want, out.Units)
+ }
+ }
+ if strings.Contains(got, "sidecar@a") {
+ t.Errorf("sweep stopped an inactive unit with no job: %v", out.Units)
+ }
+ calls := ctl.joined()
+ if !strings.Contains(calls, "stop carlos-instance@a.example.com.service") || !strings.Contains(calls, "stop carlos-instance@b.example.com.service") {
+ t.Errorf("stops not issued:\n%s", calls)
+ }
+}
+
+func TestReadyReflectsTheProbeVerdict(t *testing.T) {
+ ctl := &fakeCtl{}
+ s, c := newServer(t, ctl)
+ // The fake "start carlos-instance-probe.service" writes the report the
+ // unit's StandardOutput=truncate: would.
+ report := ProbeReport{Ready: false, Lines: []VerdictLine{{Check: "pid1", Verdict: "fail", Detail: "pid=4242"}}}
+ ctl.answers = map[string]string{}
+ ctl.fail = map[string]error{}
+ writeReport := func(r ProbeReport) {
+ b, _ := json.Marshal(r)
+ if err := os.WriteFile(s.ProbeOut, b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ writeReport(report)
+ s.RunProbe(context.Background())
+ resp, err := c.Get("http://unix/ready")
+ if err != nil || resp.StatusCode != 503 {
+ t.Fatalf("not-ready: %v %v", resp, err)
+ }
+ var got ProbeReport
+ if err := json.NewDecoder(resp.Body).Decode(&got); err != nil || got.Ready || len(got.Lines) != 1 {
+ t.Errorf("body = %+v, %v", got, err)
+ }
+ writeReport(ProbeReport{Ready: true, Lines: []VerdictLine{{Check: "pid1", Verdict: "ok"}}})
+ s.RunProbe(context.Background())
+ if resp, err := c.Get("http://unix/ready"); err != nil || resp.StatusCode != 204 {
+ t.Fatalf("ready: %v %v", resp, err)
+ }
+ if !strings.Contains(ctl.joined(), "start carlos-instance-probe.service") {
+ t.Errorf("probe unit not started: %s", ctl.joined())
+ }
+ // A missing fixture is never a pass.
+ s.FixtureOK = func() error { return errors.New("probe fixture absent") }
+ s.RunProbe(context.Background())
+ if resp, _ := c.Get("http://unix/ready"); resp.StatusCode != 503 {
+ t.Errorf("fixture absent yet ready")
+ }
+}
+
+func TestPeerCheckClosesTheConnectionWithoutAResponse(t *testing.T) {
+ ctl := &fakeCtl{}
+ s, c := newServer(t, ctl)
+ s.PeerOK = func(net.Conn) error { return errors.New("not the agent") }
+ _, err := c.Get("http://unix/ready")
+ if err == nil {
+ t.Fatal("a refused peer got an HTTP response")
+ }
+ if ctl.joined() != "" {
+ t.Errorf("systemctl ran for a refused peer: %s", ctl.joined())
+ }
+}
+
+func TestWaitCgroupEmptyTreatsAnAbsentCgroupAsEmpty(t *testing.T) {
+ root := t.TempDir()
+ if err := WaitCgroupEmpty(context.Background(), root, "carlos-instances-h.slice", "carlos-instance@h.service", 10*time.Millisecond); err != nil {
+ t.Errorf("absent cgroup: %v", err)
+ }
+}