textmachine/platform/internal/runner/runner_test.go

286 lines
11 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, "", false, 0, 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, 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, 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, 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, 0, 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, 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, 0, nil), "--accept-rebill=0.000000") {
t.Error("a zero consent rendered a flag: zero means no consent")
}
}