textmachine/platform/cmd/tmplatformd/runner_test.go

163 lines
7.4 KiB
Go

package main
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"textmachine/platform/internal/books"
"textmachine/platform/internal/config"
"textmachine/platform/internal/exports"
"textmachine/platform/internal/jobs"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/readmodel"
"textmachine/platform/internal/runs"
)
// A relative exit-marker command passes os.Stat and is then refused by systemd when the FIRST run
// tries to start — measured on systemd 259: "neither a valid executable name nor an absolute path",
// which fails the whole unit. The platform then claims the attempt, fails to spawn, gives the claim
// back and repeats on every sweep, so the only symptom is that nothing ever runs (register row
// PD-165). Refused at boot instead, where an operator is watching, exactly as StateDir is.
//
// Mutation caught: dropping the filepath.IsAbs guard and relying on os.Stat.
func TestARelativeExitMarkerCommandIsRefusedAtBoot(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "tmplatformctl")
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
// The same file, named relatively from a working directory where it really is present: os.Stat
// says yes and systemd says no, which is the whole of the defect.
t.Chdir(dir)
if _, err := markerArgv("./tmplatformctl"); err == nil {
t.Fatal("a relative exit-marker command was accepted; systemd refuses it and every run would fail to start")
} else if !strings.Contains(err.Error(), "absolute") {
t.Errorf("the refusal does not name the cause: %v", err)
}
argv, err := markerArgv(bin)
if err != nil {
t.Fatalf("an absolute command was refused: %v", err)
}
if len(argv) != 2 || argv[0] != bin || argv[1] != "exit-marker" {
t.Errorf("argv = %v", argv)
}
}
// The operator's knobs reach the code that reads them.
//
// Nothing witnessed this: `startRunner` needs a database, a systemd bus and a queue, so its config
// literal was observable only by running the whole daemon — and `RunBudget` was declared,
// documented and left at its zero value through a whole pack because of it.
//
// Mutation caught: dropping any assignment in runsConfig.
func TestTheOperatorsRunnerKnobsReachTheReconciler(t *testing.T) {
cfg := config.Config{}
cfg.Runner.StateDir = "/var/lib/tm"
cfg.Runner.EngineBinary = "/opt/tm/tmctl"
cfg.Runner.MemoryMax = "2G"
cfg.Runner.TasksMax = 64
cfg.Runner.AllowEngineVersionChange = true
cfg.Runner.ResyncEvery = 5 * time.Minute
cfg.Runner.RunBudget = 90 * time.Second
cfg.Runner.KeysFile = "/etc/tm/keys.env"
got := runsConfig(cfg, []string{"/usr/bin/tmplatformctl", "exit-marker"}, []string{"--max-cost"})
switch {
case got.RunBudget != cfg.Runner.RunBudget:
t.Errorf("RunBudget is %v, want the operator's %v: TM_PLATFORM_RUN_BUDGET does nothing",
got.RunBudget, cfg.Runner.RunBudget)
case got.KeysFile != cfg.Runner.KeysFile:
// The exact regression class this test exists for: dropped here, the engine silently starves
// for keys again (row 211) at the price of a whole paid run.
t.Errorf("KeysFile is %q, want the operator's %q: TM_PLATFORM_ENGINE_KEYS_PATH does nothing",
got.KeysFile, cfg.Runner.KeysFile)
case got.ResyncEvery != cfg.Runner.ResyncEvery:
t.Errorf("ResyncEvery is %v, want %v", got.ResyncEvery, cfg.Runner.ResyncEvery)
case got.StateDir != cfg.Runner.StateDir || got.EngineBinary != cfg.Runner.EngineBinary:
t.Errorf("the paths did not arrive: %+v", got)
case got.MemoryMax != cfg.Runner.MemoryMax || got.TasksMax != cfg.Runner.TasksMax:
t.Errorf("the unit's limits did not arrive: %+v", got)
case !got.AllowEngineVersionChange:
t.Error("the engine-version switch did not arrive")
case len(got.MarkerArgv) != 2 || len(got.Ceiling) != 1:
t.Errorf("the argv the caller assembled did not arrive: %+v", got)
}
}
// Every reconciler this daemon runs is in the list, and a component that is not wired takes no pass.
//
// Nothing witnessed this either, and it is the same class as the knobs above one level up: a pass
// added to the tick and to nothing else is invisible until somebody notices that expired artifacts
// are not being collected — which is a disk filling up, not an error.
//
// Mutation caught: dropping any `if s.X != nil` arm (a wired component gets no pass), or adding a
// pass unconditionally (a nil component is called and the whole tick panics).
func TestEveryWiredReconcilerTakesAPassAndAnUnwiredOneDoesNot(t *testing.T) {
names := func(s sweeps) []string {
var out []string
for _, p := range s.passes(time.Minute, slog.New(slog.DiscardHandler)) {
out = append(out, p.name)
}
return out
}
// A read replica: runs only, because `sweeps.runs` is the one field the tick has no arm for —
// a daemon without it does not start a sweep at all.
bare := names(sweeps{runs: &runs.Service{}})
if len(bare) != 1 || bare[0] != "runs" {
t.Errorf("a bare daemon runs %v, want only the run reconciler", bare)
}
full := names(sweeps{runs: &runs.Service{}, reader: &readmodel.Service{}, books: &books.Service{},
exports: &exports.Service{}, db: &pgstore.Store{}})
want := []string{"runs", "readmodel", "intake", "exports", "idempotency"}
if len(full) != len(want) {
t.Fatalf("a fully wired daemon runs %v, want %v", full, want)
}
for i := range want {
if full[i] != want[i] {
t.Errorf("pass %d is %q, want %q — the order is the tick's own: money settles before the "+
"work that reads a whole book", i, full[i], want[i])
}
}
// The export GC takes the ORDINARY budget and not the intake's: nothing inside it calls the
// engine, and giving it the larger one would let a directory of unlinks starve the tick.
for _, p := range (sweeps{runs: &runs.Service{}, exports: &exports.Service{}}).passes(time.Minute, slog.New(slog.DiscardHandler)) {
if p.name == "exports" && p.budget != time.Minute {
t.Errorf("the export sweep takes %v, want the ordinary pass budget", p.budget)
}
}
}
// The export door's knobs reach the code that reads them — the same witness the reconciler's have,
// for the same reason and against the same regression class.
//
// ⚠ `StaleAfter` is the one that cannot come from the environment and still must be right: it has to
// OUTLIVE one whole job, or the GC gives up on builds that are merely slow and answers a poll with
// `build_interrupted` while the worker is still working.
//
// Mutation caught: dropping any assignment in exportsConfig; making StaleAfter shorter than a job.
func TestTheOperatorsExportKnobsReachTheDoor(t *testing.T) {
cfg := config.Config{}
cfg.Runner.EngineBinary = "/opt/tm/v1/tmctl"
cfg.Export.Formats = []string{"epub", "txt"}
cfg.Export.Dir = "/var/lib/tm/exports"
cfg.Export.TTL = 6 * time.Hour
got := exportsConfig(cfg)
switch {
case got.EngineBinary != cfg.Runner.EngineBinary:
t.Errorf("EngineBinary is %q: the door would build with nothing", got.EngineBinary)
case len(got.Formats) != 2 || got.Formats[0] != "epub":
t.Errorf("Formats are %q: TM_PLATFORM_EXPORT_FORMATS does nothing", got.Formats)
case got.Dir != cfg.Export.Dir:
t.Errorf("Dir is %q, want the operator's %q", got.Dir, cfg.Export.Dir)
case got.TTL != cfg.Export.TTL:
t.Errorf("TTL is %v, want the operator's %v: TM_PLATFORM_EXPORT_TTL does nothing", got.TTL, cfg.Export.TTL)
case got.StaleAfter <= jobs.JobTimeout:
t.Errorf("StaleAfter is %v and one job may take %v: the GC would give up on builds that are "+
"still running and answer their polls with a failure", got.StaleAfter, jobs.JobTimeout)
}
}