textmachine/platform/internal/runner/systemd_test.go

184 lines
7 KiB
Go

package runner
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// These tests use the REAL systemd user manager. They exist because every property this package
// depends on is a property of systemd, not of Go: that a transient unit outlives its spawner, that
// ExecStopPost still runs when the kernel kills the process, and that a per-run memory limit is
// enforced at all. Reading the manual page proves none of them — and one of them (the limit) is
// false at the default placement, which is why Slice is not decoration.
func systemdOrSkip(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("systemd-run"); err != nil {
t.Skip("no systemd-run: the transient-unit properties cannot be measured on this host")
}
// The gate is the CAPABILITY, not a sentence: this used to skip on the words "Failed to connect to
// bus" and systemd 259 says "Failed to connect to user scope bus", so on a host without a manager
// the tests FAILED instead of skipping — a gate that stopped gating when someone else edited a
// string. What is asked instead is the thing these tests need: can this process reach its own user
// manager at all. The skip is loud, like the database one, because `make check` names skips and a
// silent one reads as coverage.
out, err := exec.CommandContext(t.Context(), "systemctl", "--user", "show", "--property=Version").CombinedOutput()
if err != nil {
t.Skipf("no reachable systemd user manager (%s): the transient-unit properties cannot be measured on this host",
bytes.TrimSpace(out))
}
}
// testUnit names a unit that cannot collide with another run of this same test.
//
// Not decoration: the unit name is a global of the user's systemd manager, so a fixed name makes two
// overlapping test processes — `go test` twice, or a re-run started before the previous unit was
// collected — fail with "unit already exists". Reproduced deliberately (two concurrent runs of this
// package: one green, one red) before fixing it, because a flake that only appears under a second
// runner is exactly the kind that gets blamed on the code under test.
func testUnit(t *testing.T) string {
t.Helper()
return fmt.Sprintf("tm-test-%s-%d", strings.ToLower(strings.TrimPrefix(t.Name(), "Test"))[:8], os.Getpid())
}
// marker writer stand-in: the CLI subcommand does exactly this, and pointing the unit at the test
// binary would drag the whole test framework into a systemd unit.
func markerScript(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "marker.sh")
body := `#!/bin/sh
printf '{"unit":"%s","result":"%s","code":"%s","status":"%s","at":"2026-08-08T00:00:00Z"}\n' \
"$2" "$SERVICE_RESULT" "$EXIT_CODE" "$EXIT_STATUS" > "$1.tmp" && mv "$1.tmp" "$1"
`
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
t.Fatal(err)
}
return path
}
func waitForMarker(t *testing.T, path string) Marker {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
m, err := ReadMarker(path)
if err == nil {
return m
}
if !errors.Is(err, ErrNoMarker) {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("no exit marker at %s after 30s", path)
return Marker{}
}
// The engine's exit code has to survive the unit being collected — and it does not survive it in
// systemd's own state: with --collect the unit is unloaded and `show` answers ExecMainStatus=0 for a
// process that exited 3. The marker is what keeps the fact.
func TestAFinishedUnitReportsItsExitCodeThroughTheMarker(t *testing.T) {
systemdOrSkip(t)
dir := t.TempDir()
marker := filepath.Join(dir, "X-1.exit")
unit := testUnit(t)
r := New(nil)
t.Cleanup(func() { _ = r.Stop(t.Context(), unit) })
if err := r.Start(t.Context(), Spec{
Unit: unit, Binary: "/bin/sh", Args: []string{"-c", "exit 3"}, Workdir: dir,
ExitMarker: marker, MarkerArgv: []string{markerScript(t, dir), marker, unit},
}); err != nil {
t.Fatal(err)
}
m := waitForMarker(t, marker)
code, ok := m.Exited()
if !ok || code != 3 {
t.Fatalf("marker %+v: Exited() = %d, %v; want 3, true", m, code, ok)
}
alive, err := r.Alive(t.Context(), unit)
if err != nil {
t.Fatal(err)
}
if alive {
t.Error("a finished unit reported itself alive")
}
}
// A run outlives the process that started it. This is the whole reason the seam is a transient unit
// rather than a child (D39.106): a deploy of the platform must not take a paid translation with it.
func TestARunOutlivesTheProcessThatStartedIt(t *testing.T) {
systemdOrSkip(t)
dir := t.TempDir()
marker := filepath.Join(dir, "X-2.exit")
unit := testUnit(t)
t.Cleanup(func() { _ = New(nil).Stop(t.Context(), unit) })
// A separate process starts the unit and then exits, which is what a platform restart looks like
// to the run.
script := filepath.Join(dir, "spawn.sh")
body := "#!/bin/sh\nexec systemd-run --user --unit=" + unit + " --collect --quiet" +
" '--property=Slice=" + Slice + "'" +
" '--property=ExecStopPost=" + markerScript(t, dir) + " " + marker + " " + unit + "'" +
" -- /bin/sh -c 'sleep 2'\n"
if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
t.Fatal(err)
}
if out, err := exec.CommandContext(t.Context(), script).CombinedOutput(); err != nil {
t.Fatalf("spawner: %v: %s", err, out)
}
alive, err := New(nil).Alive(t.Context(), unit)
if err != nil {
t.Fatal(err)
}
if !alive {
t.Fatal("the unit died with the process that started it")
}
if m := waitForMarker(t, marker); m.Result != "success" {
t.Errorf("marker after a clean run: %+v", m)
}
}
// PD-13's answer, measured rather than declared. ⚠ The slice is what makes it true: in the user
// manager's default app.slice the leaf cgroup gets no control files at all, so MemoryMax= is
// accepted, reported back by `show`, and enforces nothing.
func TestARunIsBoundedByItsOwnCgroup(t *testing.T) {
systemdOrSkip(t)
dir := t.TempDir()
marker := filepath.Join(dir, "X-3.exit")
unit := testUnit(t)
hog := filepath.Join(dir, "hog.sh")
// Faults every page in: an untouched allocation is never charged to the cgroup, so a test that
// only allocates passes whether the limit works or not.
body := `#!/bin/sh
exec /usr/bin/env python3 -c "
chunks=[]
for i in range(400):
b=bytearray(1024*1024)
for j in range(0,len(b),4096): b[j]=1
chunks.append(b)
"
`
if err := os.WriteFile(hog, []byte(body), 0o700); err != nil {
t.Fatal(err)
}
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("no python3: the memory limit cannot be exercised on this host")
}
r := New(nil)
t.Cleanup(func() { _ = r.Stop(t.Context(), unit) })
if err := r.Start(t.Context(), Spec{
Unit: unit, Binary: hog, Workdir: dir, MemoryMax: "64M", TasksMax: 32,
ExitMarker: marker, MarkerArgv: []string{markerScript(t, dir), marker, unit},
}); err != nil {
t.Fatal(err)
}
m := waitForMarker(t, marker)
if m.Result != "oom-kill" {
t.Fatalf("a run that touched 400 MiB under MemoryMax=64M ended as %q, want oom-kill: "+
"the limit is not being enforced (check that the leaf cgroup of %s has memory.max)", m.Result, Slice)
}
}