250 lines
9.2 KiB
Go
250 lines
9.2 KiB
Go
package runner
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"textmachine/platform/internal/money"
|
|
)
|
|
|
|
// The argv is the whole of this package's contract with systemd: every ratified property of the
|
|
// seam (D39.106 §2) is a flag here and nowhere else, so the assertions are on the flags themselves.
|
|
func TestTheUnitCarriesEveryRatifiedProperty(t *testing.T) {
|
|
r := &Runner{}
|
|
got := r.startArgv(Spec{
|
|
Unit: "tm-run-X-1",
|
|
Binary: "/opt/textmachine/engine/2026.08.01/tmctl",
|
|
Args: []string{"translate", "--config", "/srv/books/a/book.yaml"},
|
|
Workdir: "/srv/books/a",
|
|
ExitMarker: "/var/lib/tmplatform/runs/X-1.exit",
|
|
MarkerArgv: []string{"/usr/local/bin/tmplatformctl", "exit-marker", "/var/lib/tmplatform/runs/X-1.exit", "tm-run-X-1"},
|
|
MemoryMax: "4G",
|
|
TasksMax: 64,
|
|
})
|
|
for _, want := range []string{
|
|
"--user",
|
|
"--unit=tm-run-X-1",
|
|
"--collect",
|
|
"--property=Restart=no",
|
|
"--property=KillSignal=SIGTERM",
|
|
"--property=KillMode=mixed",
|
|
"--property=Slice=" + Slice,
|
|
"--property=WorkingDirectory=/srv/books/a",
|
|
"--property=MemoryMax=4G",
|
|
"--property=MemorySwapMax=0",
|
|
"--property=TasksMax=64",
|
|
"--property=TimeoutStopSec=600",
|
|
"--property=ExecStopPost=/usr/local/bin/tmplatformctl exit-marker /var/lib/tmplatform/runs/X-1.exit tm-run-X-1",
|
|
} {
|
|
if !slices.Contains(got, want) {
|
|
t.Errorf("systemd-run argv is missing %q\ngot: %v", want, got)
|
|
}
|
|
}
|
|
// The binary and its arguments come after the separator, or systemd-run reads them as its own.
|
|
sep := slices.Index(got, "--")
|
|
if sep < 0 {
|
|
t.Fatalf("no -- separator in %v", got)
|
|
}
|
|
if want := []string{"/opt/textmachine/engine/2026.08.01/tmctl", "translate", "--config", "/srv/books/a/book.yaml"}; !slices.Equal(got[sep+1:], want) {
|
|
t.Errorf("command after --: %v, want %v", got[sep+1:], want)
|
|
}
|
|
}
|
|
|
|
// A state directory with a space in it is the ordinary way this breaks, and it breaks silently: the
|
|
// marker is written somewhere else and the reconciler concludes the run never ended.
|
|
func TestAMarkerPathWithSpacesStaysOneArgument(t *testing.T) {
|
|
got := (&Runner{}).startArgv(Spec{
|
|
Unit: "u", Binary: "/bin/true", Workdir: "/w", ExitMarker: "x",
|
|
MarkerArgv: []string{"/usr/local/bin/ctl", "exit-marker", "/var/lib/tm platform/x.exit", "u"},
|
|
})
|
|
want := `--property=ExecStopPost=/usr/local/bin/ctl exit-marker "/var/lib/tm platform/x.exit" u`
|
|
if !slices.Contains(got, want) {
|
|
t.Errorf("quoted ExecStopPost missing\nwant %q\ngot %v", want, got)
|
|
}
|
|
}
|
|
|
|
// The INFO line names the command and never its arguments. The arguments carry the book's
|
|
// configuration path and the run's ceiling in dollars, and money does not enter the platform's INFO
|
|
// stream at all (D39.84, PD-99).
|
|
func TestTheStartLineNamesTheCommandAndNotItsArguments(t *testing.T) {
|
|
var logs bytes.Buffer
|
|
r := &Runner{
|
|
Log: slog.New(slog.NewJSONHandler(&logs, nil)),
|
|
run: func(context.Context, string, ...string) ([]byte, error) { return nil, nil },
|
|
}
|
|
err := r.Start(t.Context(), Spec{
|
|
Unit: "tm-run-bk_secret-1", Binary: "/opt/engine/2026.08.01/tmctl",
|
|
Args: []string{"translate", "--config", "/srv/books/bk_secret/book.yaml", "--ceiling-usd", "3.000000"},
|
|
Workdir: "/srv/books/bk_secret",
|
|
ExitMarker: "/var/lib/tmplatform/runs/x.exit",
|
|
MarkerArgv: []string{"/usr/local/bin/tmplatformctl", "exit-marker"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := logs.String()
|
|
if !strings.Contains(got, `"command":"/opt/engine/2026.08.01/tmctl"`) {
|
|
t.Errorf("the start line does not name the command: %s", got)
|
|
}
|
|
for _, forbidden := range []string{"3.000000", "--ceiling-usd", "book.yaml", "args"} {
|
|
if strings.Contains(got, forbidden) {
|
|
t.Errorf("%q reached the INFO stream: %s", forbidden, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStartRefusesAnIncompleteSpec(t *testing.T) {
|
|
r := &Runner{run: func(context.Context, string, ...string) ([]byte, error) { return nil, nil }}
|
|
if err := r.Start(t.Context(), Spec{Unit: "u"}); err == nil {
|
|
t.Fatal("a spec without a binary, workdir or marker was accepted")
|
|
}
|
|
}
|
|
|
|
// Alive must not turn "I could not ask" into "the run is gone": that mistake restarts a live engine
|
|
// against its own project lock.
|
|
func TestAnUnreachableBusIsNotAFinishedRun(t *testing.T) {
|
|
r := &Runner{run: func(context.Context, string, ...string) ([]byte, error) {
|
|
return []byte("Failed to connect to bus: No medium found"), errors.New("exit 1")
|
|
}}
|
|
if _, err := r.Alive(t.Context(), "tm-run-X-1"); err == nil {
|
|
t.Fatal("Alive reported a verdict when the bus did not answer")
|
|
}
|
|
}
|
|
|
|
func TestAliveReadsTheActiveState(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
state string
|
|
alive bool
|
|
}{{"active", true}, {"activating", true}, {"deactivating", true}, {"inactive", false}, {"failed", false}} {
|
|
r := &Runner{run: func(context.Context, string, ...string) ([]byte, error) {
|
|
return []byte(tc.state + "\n"), nil
|
|
}}
|
|
got, err := r.Alive(t.Context(), "u")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != tc.alive {
|
|
t.Errorf("ActiveState=%s: alive=%v, want %v", tc.state, got, tc.alive)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stopping something that is already gone is the state the caller wanted. A reconciler that read it
|
|
// as a failure would retry its own success forever.
|
|
func TestStoppingAUnitThatIsGoneSucceeds(t *testing.T) {
|
|
r := &Runner{run: func(context.Context, string, ...string) ([]byte, error) {
|
|
return []byte("Failed to stop tm-run-X-1.service: Unit tm-run-X-1.service not loaded."), errors.New("exit 5")
|
|
}}
|
|
if err := r.Stop(t.Context(), "tm-run-X-1"); err != nil {
|
|
t.Fatalf("stopping an absent unit: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheCeilingIsRefusedUntilTheEngineCanBeToldIt(t *testing.T) {
|
|
var unwired CeilingTemplate
|
|
if _, err := unwired.Args(1_000_000); !errors.Is(err, ErrCeilingNotWired) {
|
|
t.Fatalf("an unwired ceiling gave %v, want ErrCeilingNotWired", err)
|
|
}
|
|
if _, err := ParseCeilingTemplate("--ceiling-usd 5"); err == nil {
|
|
t.Fatal("a template without {{usd}} was accepted; it would send a constant ceiling")
|
|
}
|
|
tpl, err := ParseCeilingTemplate("--ceiling-usd {{usd}}")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := tpl.Args(money.MicroUSD(2_500_000))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.Equal(got, []string{"--ceiling-usd", "2.500000"}) {
|
|
t.Errorf("rendered ceiling %v", got)
|
|
}
|
|
if _, err := tpl.Args(0); err == nil {
|
|
t.Fatal("a zero ceiling was accepted; R7 forbids a ledger with no ceiling")
|
|
}
|
|
}
|
|
|
|
// tmctl requires --config on every command that touches a book, so an invocation without it never
|
|
// reaches the book at all — measured on a binary built from HEAD.
|
|
func TestEveryEngineInvocationNamesTheBookConfig(t *testing.T) {
|
|
tr := TranslateArgs("/srv/books/a", true, []string{"--ceiling-usd", "1.000000"})
|
|
if !slices.Contains(tr, "--config") || !slices.Contains(tr, "/srv/books/a/book.yaml") {
|
|
t.Errorf("translate argv without --config: %v", tr)
|
|
}
|
|
if !slices.Contains(tr, "--verify-bank") {
|
|
t.Errorf("verify_bank did not reach the engine: %v", tr)
|
|
}
|
|
if slices.Contains(TranslateArgs("/w", false, nil), "--verify-bank") {
|
|
t.Error("verify-bank was sent for a run that did not ask for it")
|
|
}
|
|
st := StatusArgs("/srv/books/a")
|
|
if !slices.Contains(st, "--config") || !slices.Contains(st, "--json") {
|
|
t.Errorf("status argv: %v", st)
|
|
}
|
|
}
|
|
|
|
func TestTheMarkerSurvivesAndIsReadBack(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "runs", "X-1.exit")
|
|
if _, err := ReadMarker(path); !errors.Is(err, ErrNoMarker) {
|
|
t.Fatalf("a missing marker gave %v, want ErrNoMarker", err)
|
|
}
|
|
if err := WriteMarker(path, Marker{Unit: "tm-run-X-1", Result: "exit-code", Code: "exited", Status: "3"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := ReadMarker(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
code, ok := got.Exited()
|
|
if !ok || code != 3 {
|
|
t.Errorf("Exited() = %d, %v; want 3, true", code, ok)
|
|
}
|
|
if got.At.IsZero() {
|
|
t.Error("the marker carries no time")
|
|
}
|
|
// Nothing but the marker is left behind: a temp file in the same directory would be read by the
|
|
// reconciler's glob one day.
|
|
entries, err := os.ReadDir(filepath.Dir(path))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Errorf("directory holds %d files, want just the marker", len(entries))
|
|
}
|
|
}
|
|
|
|
// A killed process has no exit code to map onto the engine's exit contract, and reading one anyway
|
|
// would turn an OOM kill into "the engine exited 0".
|
|
func TestAKilledUnitHasNoExitCodeToRead(t *testing.T) {
|
|
for _, m := range []Marker{
|
|
{Result: "oom-kill", Code: "killed", Status: "TERM"},
|
|
{Result: "success", Code: "killed", Status: "TERM"},
|
|
{Result: "timeout", Code: "killed", Status: "KILL"},
|
|
} {
|
|
if _, ok := m.Exited(); ok {
|
|
t.Errorf("%+v reported an exit code", m)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMarkerFromEnvReadsWhatSystemdSets(t *testing.T) {
|
|
env := map[string]string{"SERVICE_RESULT": "oom-kill", "EXIT_CODE": "killed", "EXIT_STATUS": "TERM"}
|
|
m := MarkerFromEnv("tm-run-X-1", func(k string) string { return env[k] })
|
|
if m.Result != "oom-kill" || m.Code != "killed" || m.Status != "TERM" || m.Unit != "tm-run-X-1" {
|
|
t.Errorf("marker from environment: %+v", m)
|
|
}
|
|
}
|
|
|
|
func TestQuoteArgvEscapesWhatWouldSplit(t *testing.T) {
|
|
got := quoteArgv([]string{"/bin/ctl", `a"b`, `c\d`, "plain"})
|
|
if !strings.Contains(got, `"a\"b"`) || !strings.Contains(got, `"c\\d"`) || !strings.HasSuffix(got, " plain") {
|
|
t.Errorf("quoted argv: %s", got)
|
|
}
|
|
}
|