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" // ⚠ The ONE setting this mapping takes from outside the runner's own section, and the reason it is // set here in the INTAKE's: the root belongs to the intake and the run door only asks about it. cfg.Intake.BooksDir = "/srv/textmachine/books" got := runsConfig(cfg, []string{"/usr/bin/tmplatformctl", "exit-marker"}, []string{"--max-cost"}) switch { case got.BooksDir != cfg.Intake.BooksDir: // ⛔ ITS OWN CASE BECAUSE ITS ABSENCE IS SILENT AND COSTS THE TRUTH AT THE DOOR. Unwired, the // door cannot tell "this book's directory is gone" from "the volume under every book is gone": // `books.Owns("", …)` is false for every book, so a book whose own directory vanished is // answered `503` and «this deployment cannot start runs» instead of `409 book_not_ready` with // `source_gone` — the PD-192 confusion the five pins of `runs.sourceThere` exist to prevent, // back in one dropped line. Money is untouched either way, which is exactly why nothing else // would notice: a mutation of this line survived the whole battery (adversarial pass, 11.09). t.Errorf("BooksDir is %q, want the intake's %q: the run door cannot tell whose fault a missing directory is", got.BooksDir, cfg.Intake.BooksDir) 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) } } // The operator's intake knobs reach the intake, and the cap among them. // // Same class as the runner's knobs above and the same reason for existing: a knob dropped from the // wiring changes nothing visible — the service runs on its package default while the boot line // prints the number the operator set, so the configuration LOOKS applied. The cap is the one that // costs memory when it is silently four instead of the operator's figure. func TestTheOperatorsIntakeKnobsReachTheIntake(t *testing.T) { cfg := config.Config{} cfg.Intake.BooksDir = "/srv/tm/books" cfg.Intake.BookTemplate = "/etc/tm/book.yaml" // Distinct from the package default (books.DefaultMaxCuts) on purpose: equal to it, this // assertion would pass on a wiring that dropped the field entirely. cfg.Intake.MaxCuts = books.DefaultMaxCuts + 3 cfg.Runner.EngineBinary = "/opt/tm/tmctl" cfg.LanguagePairs = []config.LanguagePair{{Source: "zh", Target: "ru", Available: true}} got := intakeConfig(cfg) switch { case got.MaxCuts != cfg.Intake.MaxCuts: t.Errorf("MaxCuts is %d, want the operator's %d: TM_PLATFORM_MAX_CUTS does nothing and the host cuts on the package default", got.MaxCuts, cfg.Intake.MaxCuts) case got.BooksDir != cfg.Intake.BooksDir || got.BookTemplate != cfg.Intake.BookTemplate: t.Errorf("the intake's paths did not arrive: %+v", got) case got.EngineBinary != cfg.Runner.EngineBinary: t.Errorf("EngineBinary is %q, want %q", got.EngineBinary, cfg.Runner.EngineBinary) case len(got.Pairs) != 1: t.Errorf("the declared pairs did not arrive: %+v", got.Pairs) } }