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, "", false, 0, []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, "", false, 0, 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) } } // The deployment's key file reaches the engine as `--keys-file` on `translate` — and ONLY when one // is configured: an empty path passed as a flag would be refused by the engine's own parser // («--keys-file was given with no path», cmd/tmctl/invocation.go). // // Mutation caught: dropping the argument, or passing the flag with an empty value. func TestTheDeploymentKeyFileReachesTranslate(t *testing.T) { tr := TranslateArgs("/srv/books/a", false, "/etc/tm/keys.env", false, 0, nil) i := slices.Index(tr, "--keys-file") if i < 0 || i+1 >= len(tr) || tr[i+1] != "/etc/tm/keys.env" { t.Errorf("the key file did not reach the engine: %v", tr) } if slices.Contains(TranslateArgs("/srv/books/a", false, "", false, 0, nil), "--keys-file") { t.Error("an unset key file was still passed as a flag") } } 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) } } // The re-pass consents render exactly and only when granted (P10): --resnapshot bare, the consent // always CAPPED to the concrete sum — the bare blanket form must never appear. func TestTheConsentFlagsRenderExactlyWhenGranted(t *testing.T) { tr := TranslateArgs("/w", false, "", true, 1_230_000, nil) if !slices.Contains(tr, "--resnapshot") { t.Errorf("no --resnapshot: %v", tr) } if !slices.Contains(tr, "--accept-rebill=1.230000") { t.Errorf("the consent is not the capped sum: %v", tr) } for _, a := range TranslateArgs("/w", false, "", false, 0, nil) { if a == "--resnapshot" || a == "--accept-rebill" || strings.HasPrefix(a, "--accept-rebill=") { t.Errorf("an ungranted consent reached the argv: %v", a) } } if slices.Contains(TranslateArgs("/w", false, "", true, 0, nil), "--accept-rebill=0.000000") { t.Error("a zero consent rendered a flag: zero means no consent") } }