176 lines
6.5 KiB
Go
176 lines
6.5 KiB
Go
package runner
|
|
|
|
import (
|
|
"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")
|
|
}
|
|
out, err := exec.CommandContext(t.Context(), "systemctl", "--user", "show", "--property=Version").CombinedOutput()
|
|
if err != nil && strings.Contains(string(out), "Failed to connect to bus") {
|
|
t.Skip("no systemd user manager: the transient-unit properties cannot be measured on this host")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|