textmachine/backend/cmd/tmctl/migrate_cli_test.go

434 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"textmachine/backend/internal/pipeline"
"textmachine/backend/internal/store"
)
// migrate_cli_test.go is the CLI half of backlog row 174: the deploy deadlock is a SHELL fact — the
// platform learns what happened from an exit code and a line of stderr — so the contract is exercised
// where it is a contract. The migration mechanics themselves are pinned generically in
// internal/store/migrate_test.go.
// headOfSchemaAddedColumns are the columns the NEWEST migration adds. Rolling a project back one
// vintage has to take them with it: a database an older engine binary left behind does not have them,
// and a fixture that left them in place would build a state the real migrator cannot produce — the step
// and its version row commit in ONE transaction (store.applyStep), so «the DDL is there and the version
// is not» never happens outside a test.
//
// The list is coupled to the head of store.migrations deliberately. An ALTER step at the head is the
// case backlog row 49а names (those steps are not re-appliable), and a fixture that ignored it would
// re-apply the step onto its own effect and fail on a duplicate column instead of on the property the
// test is about.
// ⚠ EMPTY AT THIS HEAD, AND THAT IS A STATEMENT RATHER THAN AN OMISSION: migration 19 creates a TABLE
// (bank_basis) and adds no column to any existing one. A CREATE TABLE step is re-appliable — it carries
// IF NOT EXISTS — so rolling the version row back is the whole roll-back, and there is nothing for this
// list to take with it. The assertion below is what keeps the emptiness honest: the day a head arrives
// that DOES add a column, the version check fires and this list has to be written again.
var headOfSchemaAddedColumns []string
// headOfSchemaVersion is the migration the list above DESCRIBES. It is asserted rather than assumed,
// because a stale list does not fail where it is wrong: with a newer head the roll-back would drop this
// column while deleting the NEWER version row, so the migrator would re-apply the new step and never
// restore the old column — the "DDL missing while the version says present" state the fixture exists to
// avoid, surfacing later inside some unrelated test. The assertion turns that into one sentence here.
const headOfSchemaVersion = 19
// staleTheProjectSchema rolls the project database back one vintage — the recorded version AND the
// newest step's own columns — which is what an older engine binary left behind.
//
// These tests are about the exit codes and the output, so the roll-back is done here rather than with
// the deep fixture (the migration chain truncated to an older vintage), which is only reachable from
// inside the store package where those properties are tested.
func staleTheProjectSchema(t *testing.T, dbPath string) int {
t.Helper()
if got := store.SchemaHead(); got != headOfSchemaVersion {
t.Fatalf("the migration head moved to %d, and headOfSchemaAddedColumns still describes %d: "+
"add migration %d's own columns to that list (or empty it if the step adds none) and bump "+
"headOfSchemaVersion — otherwise this fixture rolls back the wrong vintage",
got, headOfSchemaVersion, got)
}
db, err := sql.Open("sqlite", "file:"+dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DELETE FROM schema_version WHERE version = (SELECT MAX(version) FROM schema_version)`); err != nil {
t.Fatal(err)
}
for _, col := range headOfSchemaAddedColumns {
table, name, ok := strings.Cut(col, ".")
if !ok {
t.Fatalf("headOfSchemaAddedColumns entry %q must be table.column", col)
}
if _, err := db.Exec(`ALTER TABLE ` + table + ` DROP COLUMN ` + name); err != nil {
t.Fatalf("roll %s back one vintage: %v", col, err)
}
}
return schemaVersionOf(t, dbPath)
}
// schemaVersionOf reads the recorded schema version through its own connection — the state under test
// must be asserted independently of the product code that reports it.
func schemaVersionOf(t *testing.T, dbPath string) int {
t.Helper()
db, err := sql.Open("sqlite", "file:"+dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var v int
if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&v); err != nil {
t.Fatal(err)
}
return v
}
// holdTheProject takes the project's exclusive flock the way another tmctl process would, WITHOUT
// opening the store — because a write open would migrate the database, and the state under test is a
// project that is both stale and held.
func holdTheProject(t *testing.T, dbPath string) {
t.Helper()
f, err := os.OpenFile(dbPath+".lock", os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
t.Fatal(err)
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
})
}
func projectDBOf(bookPath string) string {
return filepath.Join(filepath.Dir(bookPath), "cli-book.db")
}
func backupsOf(t *testing.T, dbPath string) []string {
t.Helper()
entries, err := os.ReadDir(backupDirFor(dbPath))
if os.IsNotExist(err) {
return nil
}
if err != nil {
t.Fatal(err)
}
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
return names
}
// mockProvider answers every call with a usable draft, so a `translate` really settles money.
func mockProvider(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
fmt.Fprint(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":"ЧЕРНОВИК ПЕРЕВОДА"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":1000,"completion_tokens":500}}`)
}))
t.Cleanup(srv.Close)
return srv
}
// TestTheDeployDeadlockIsBrokenAtTheShell drives the real binary through the exact sequence the
// platform meets on an engine upgrade: `status --json` before a spawn refuses the older schema, and the
// contract must make that refusal actionable — its own number inside the refusal band, and the two
// versions machine-readable — so the caller can migrate and start the run instead of stopping the world.
func TestTheDeployDeadlockIsBrokenAtTheShell(t *testing.T) {
if testing.Short() {
t.Skip("builds and runs the binary")
}
bin := buildTmctl(t)
bookPath := setupCLIProject(t, mockProvider(t).URL)
dbPath := projectDBOf(bookPath)
// A book an older binary left behind.
st, err := store.Open(dbPath)
if err != nil {
t.Fatal(err)
}
st.Close()
from := staleTheProjectSchema(t, dbPath)
var stderr bytes.Buffer
status := exec.Command(bin, "status", "--config", bookPath, "--json")
status.Stderr = &stderr
if got := exitCodeOf(t, status.Run()); got != exitSchemaMismatch {
t.Fatalf("`status --json` on an older schema exited %d, want %d", got, exitSchemaMismatch)
}
// The number says WHAT; these two say which way and how far — without them a caller cannot tell the
// direction a migration repairs from the one where it would loop forever.
want := fmt.Sprintf("schema_mismatch found=%d expected=%d", from, store.SchemaHead())
if !strings.Contains(stderr.String(), want) {
t.Fatalf("the refusal must carry %q machine-readably; got:\n%s", want, stderr.String())
}
if exitSchemaMismatch < refusalFirst || exitSchemaMismatch > refusalLast {
t.Fatalf("%d is outside the refusal band [%d,%d] every consumer keys on", exitSchemaMismatch, refusalFirst, refusalLast)
}
out, err := exec.Command(bin, "migrate", "--config", bookPath).Output()
if got := exitCodeOf(t, err); got != 0 {
t.Fatalf("`migrate` exited %d: %s", got, out)
}
if want := fmt.Sprintf("schema v%d -> v%d", from, store.SchemaHead()); !strings.Contains(string(out), want) {
t.Fatalf("migrate must report the transition %q; got:\n%s", want, out)
}
if got := exitCodeOf(t, exec.Command(bin, "status", "--config", bookPath, "--json").Run()); got != 0 {
t.Fatalf("`status --json` after the migration exited %d, want 0 — the deadlock is not broken", got)
}
// …and the run the migration unblocked actually starts. It follows within milliseconds, which is
// what put migrate's restore point and the paid path's pre-flight guard in the same second.
if run := exec.Command(bin, "translate", "--config", bookPath); exitCodeOf(t, run.Run()) != 0 {
t.Fatal("the run spawned right after the migration must start, not die in its pre-flight guard")
}
}
// TestTheMigrationRestorePointNeverCollidesWithThePaidPath is the deterministic half of the same fact:
// both producers are handed the SAME stamp, because in the deploy sequence they really do land in the
// same second. `store.BackupSQLite` refuses to overwrite a restore point (backlog row 173), so sharing
// the name would make the run refuse to start with exit 1 — outside the refusal band, and after a
// migration that already succeeded.
func TestTheMigrationRestorePointNeverCollidesWithThePaidPath(t *testing.T) {
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
dbPath := projectDBOf(bookPath)
st, err := store.Open(dbPath)
if err != nil {
t.Fatal(err)
}
st.Close()
staleTheProjectSchema(t, dbPath)
// PRODUCTION takes the restore point — the point of the test. Supplying the name here instead would
// only pin that the constant is non-empty, and the regression to catch is the call site dropping it.
if err := migrateCmd(bookPath, io.Discard); err != nil {
t.Fatal(err)
}
names := backupsOf(t, dbPath)
if len(names) != 1 {
t.Fatalf("restore points after the migration: %v, want exactly one", names)
}
// Strip the tag production chose and the bare second-stamp must still be free, so translate's
// pre-flight guard can take its own backup in the SAME second. Untagged, the two names are equal and
// BackupSQLite refuses to overwrite — the run the migration just unblocked would die in its
// pre-flight guard with exit 1. No wall clock is involved: the collision is decided by the name.
tagged := strings.TrimSuffix(names[0], ".db")
bare := strings.TrimSuffix(tagged, migrateSuffix)
if bare == tagged {
t.Fatalf("the migration's restore point %q carries no tag of its own — it is inside the paid path's namespace", names[0])
}
if _, err := store.BackupSQLite(dbPath, backupDirFor(dbPath), bare); err != nil {
t.Fatalf("the paid path's pre-flight backup in the SAME second must still succeed: %v", err)
}
}
// TestMigrateNeedsNeitherPricesNorModels: the $0 path must not carry the paid path's gates. A
// models.yaml whose prices are older than 120 days refuses to load (backlog row 146), and if the
// migration were built on the full config stack a stand with stale prices would meet the same deadlock
// this command exists to remove — the read command refuses, and the one thing that could migrate
// refuses too.
func TestMigrateNeedsNeitherPricesNorModels(t *testing.T) {
bookPath := setupCLIProject(t, mockProvider(t).URL)
modelsPath := filepath.Join(filepath.Dir(bookPath), "models.yaml")
raw, err := os.ReadFile(modelsPath)
if err != nil {
t.Fatal(err)
}
stale := time.Now().UTC().AddDate(0, 0, -200).Format("2006-01-02")
writeCLIFile(t, modelsPath, strings.Replace(string(raw), time.Now().UTC().Format("2006-01-02"), stale, 1))
// The contrast: the read-only projection is refused by the price gate before it reads a schema.
if got := exitCode(status(context.Background(), bookPath, true)); got != exitConfigInvalid {
t.Fatalf("setup: `status` on stale prices exited %d, want %d", got, exitConfigInvalid)
}
var out bytes.Buffer
if err := migrateCmd(bookPath, &out); err != nil {
t.Fatalf("migrate must not consult prices: %v", err)
}
if !strings.Contains(out.String(), fmt.Sprintf("v%d", store.SchemaHead())) {
t.Fatalf("migrate must report the head it brought the project to; got %q", out.String())
}
// The structural version of the same claim, which also covers the provider keys the models file
// carries: with no models.yaml at all the migration still runs, so it cannot be consulting either.
if err := os.Remove(modelsPath); err != nil {
t.Fatal(err)
}
if got := exitCode(status(context.Background(), bookPath, true)); got != exitConfigInvalid {
t.Fatalf("setup: `status` without models.yaml exited %d, want %d", got, exitConfigInvalid)
}
if err := migrateCmd(bookPath, io.Discard); err != nil {
t.Fatalf("migrate must not load the model stack at all: %v", err)
}
}
// TestMigrateKeepsTheMoneyAndRepeatsAsANoOp is the money half at the command level: a real run's
// settled spend, a reservation its process never settled, and a migration between them. `committed` is
// what the platform's ceiling argument is computed from (PD-158) and what a settle reconciles against,
// so it must survive the deploy step untouched; `reserved` is advisory and the write open's recovery
// pass is entitled to zero it.
func TestMigrateKeepsTheMoneyAndRepeatsAsANoOp(t *testing.T) {
bookPath := setupCLIProject(t, mockProvider(t).URL)
dbPath := projectDBOf(bookPath)
if err := translate(context.Background(), bookPath, false, pipeline.RebillConsent{}, false, 0, 0); err != nil {
t.Fatalf("setup run: %v", err)
}
st, err := store.Open(dbPath)
if err != nil {
t.Fatal(err)
}
committed, _, err := st.SpentUSD("cli-book")
if err != nil {
t.Fatal(err)
}
if committed <= 0 {
t.Fatalf("setup: the run must have settled money, committed=%v", committed)
}
// The reservation a killed run leaves behind.
if _, verdict, err := st.Reserve("cli-book", 0.25, store.Ceilings{BookUSD: 10, DayUSD: 10}); err != nil || verdict != store.ReserveOK {
t.Fatalf("stale reserve: %v %v", verdict, err)
}
st.Close()
from := staleTheProjectSchema(t, dbPath)
for i := range 2 {
var out bytes.Buffer
if err := migrateCmd(bookPath, &out); err != nil {
t.Fatalf("call %d: %v", i, err)
}
ro, err := store.OpenReadOnly(dbPath)
if err != nil {
t.Fatalf("call %d: the read path must work after migrating: %v", i, err)
}
gotCommitted, gotReserved, err := ro.SpentUSD("cli-book")
ro.Close()
if err != nil {
t.Fatal(err)
}
if gotCommitted != committed {
t.Fatalf("call %d: committed %v -> %v; a migration must not move settled money by a cent", i, committed, gotCommitted)
}
if gotReserved != 0 {
t.Fatalf("call %d: reserved=%v, want the recovery pass to have zeroed it", i, gotReserved)
}
// The restore point is taken for the call that MIGRATES and for no other, and it holds the
// database as it was BEFORE the step — a copy taken afterwards is not a restore point at all.
names := backupsOf(t, dbPath)
if len(names) != 1 {
t.Fatalf("call %d: restore points %v, want exactly the one the migrating call took", i, names)
}
if v := schemaVersionOf(t, filepath.Join(backupDirFor(dbPath), names[0])); v != from {
t.Fatalf("call %d: the restore point is at schema v%d, want the pre-migration v%d", i, v, from)
}
if i == 1 && !strings.Contains(out.String(), "already at schema") {
t.Fatalf("the repeat call must report a no-op; got %q", out.String())
}
}
}
func TestMigrateRefusesWithTheSameVocabularyAsEveryOtherCommand(t *testing.T) {
t.Run("a broken config", func(t *testing.T) {
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
writeCLIFile(t, bookPath, "book_id: cli-book\n bad indentation: [\n")
if got := exitCode(migrateCmd(bookPath, io.Discard)); got != exitConfigInvalid {
t.Fatalf("exit %d, want %d", got, exitConfigInvalid)
}
})
t.Run("a project another process holds", func(t *testing.T) {
// The deploy step runs while the operator believes the runs are stopped. If one is not, the
// answer is "come back later" — never a migration under a live writer's feet, whose reservations
// the recovery pass would zero. And nothing may be written for that refusal: a restore point
// taken before the lock would copy the whole database once per retry.
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
dbPath := projectDBOf(bookPath)
st, err := store.Open(dbPath)
if err != nil {
t.Fatal(err)
}
st.Close()
staleTheProjectSchema(t, dbPath)
holdTheProject(t, dbPath)
if got := exitCode(migrateCmd(bookPath, io.Discard)); got != exitProjectLocked {
t.Fatalf("exit %d, want %d", got, exitProjectLocked)
}
if got := backupsOf(t, dbPath); len(got) != 0 {
t.Fatalf("a refused migration wrote %v; nothing may be written for a project it does not own", got)
}
// The retry the refusal invites reaches the lock too, instead of dying on the leftovers of the
// previous attempt.
if got := exitCode(migrateCmd(bookPath, io.Discard)); got != exitProjectLocked {
t.Fatalf("retry exited %d, want %d again", got, exitProjectLocked)
}
})
t.Run("a project newer than this binary", func(t *testing.T) {
// The direction a migration cannot repair. It must not read as success, or a caller looping
// "status refused → migrate → retry" would loop forever on a rolled-back binary.
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
dbPath := projectDBOf(bookPath)
st, err := store.Open(dbPath)
if err != nil {
t.Fatal(err)
}
st.Close()
db, err := sql.Open("sqlite", "file:"+dbPath)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, store.SchemaHead()+7); err != nil {
t.Fatal(err)
}
db.Close()
err = migrateCmd(bookPath, io.Discard)
if got := exitCode(err); got != exitSchemaMismatch {
t.Fatalf("exit %d, want %d", got, exitSchemaMismatch)
}
if !strings.Contains(err.Error(), fmt.Sprintf("schema_mismatch found=%d expected=%d", store.SchemaHead()+7, store.SchemaHead())) {
t.Fatalf("the refusal must name both versions machine-readably: %v", err)
}
if got := backupsOf(t, dbPath); len(got) != 0 {
t.Fatalf("a refused migration wrote %v", got)
}
})
t.Run("a book that has never been run", func(t *testing.T) {
// The deploy sweep does not have to know which books have a database yet. Nothing is backed up:
// a database this call creates has no history to lose.
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
var out bytes.Buffer
if err := migrateCmd(bookPath, &out); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "created") {
t.Fatalf("migrate must say it created the project; got %q", out.String())
}
if v := schemaVersionOf(t, projectDBOf(bookPath)); v != store.SchemaHead() {
t.Fatalf("created at v%d, want v%d", v, store.SchemaHead())
}
if got := backupsOf(t, projectDBOf(bookPath)); len(got) != 0 {
t.Fatalf("a project being created was backed up: %v", got)
}
})
}