textmachine/platform/internal/runs/admission_test.go

362 lines
16 KiB
Go

package runs
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
"textmachine/platform/internal/books"
"textmachine/platform/internal/money"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/pricing"
"textmachine/platform/internal/runner"
)
// onTheVolume moves the fixture's book onto a books ROOT shaped like a deployment's — a marked
// storage directory with the book's own directory under it — and answers both paths.
//
// The shape matters to what is being tested: the door tells "this book's directory is gone" from
// "the volume under every book is gone" by the storage MARKER, so a fixture whose book sits in a
// bare temporary directory could only ever produce the second answer.
func onTheVolume(t *testing.T, f *fixture, bookID string) (root, dir string) {
t.Helper()
root = t.TempDir()
if err := os.WriteFile(filepath.Join(root, books.StorageMarker), []byte("test"), 0o600); err != nil {
t.Fatal(err)
}
dir = filepath.Join(root, bookID)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if _, err := f.store.Pool().Exec(f.ctx, `update books set workdir = $2 where id = $1`, bookID, dir); err != nil {
t.Fatal(err)
}
f.svc.Cfg.BooksDir = root
return root, dir
}
// A run over a book whose directory is not there is refused AT THE DOOR, before the hold.
//
// What it used to do instead is the whole of PD-162: the offset the spawn reads maps a missing
// journal onto "zero, no error" — right for a first run — so nothing on the way in looked at the
// directory at all. The run was admitted, the money was held, every spawn failed and no path ever
// reached a terminal state: the user saw "translating" for good and the credit stayed frozen until
// a person came with `tmplatformctl run abandon`.
//
// The test ends by putting the directory back and starting the same run: that is what says the
// money was genuinely at stake here and the refusal is the directory's doing, not the fixture's.
func TestABookWhoseDirectoryIsGoneIsRefusedBeforeTheHold(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
_, dir := onTheVolume(t, f, book)
before := f.account(t)
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)})
if !errors.Is(err, ErrSourceGone) {
t.Fatalf("a start over a missing directory answered %v, want ErrSourceGone", err)
}
// It travels as a kind of `book_not_ready`, which is how it reaches the wire at all: the client
// is told the book cannot be translated, and the cause beside it says waiting will not help.
if !errors.Is(err, ErrBookNotReady) {
t.Errorf("the refusal is not a book_not_ready: %v", err)
}
acct := f.account(t)
if acct.Reserved != before.Reserved || acct.Balance != before.Balance {
t.Fatalf("a refused start moved money: %+v -> %+v", before, acct)
}
if acct.Balance != acct.LedgerSum {
t.Fatalf("the cached balance and the ledger disagree: %s vs %s", acct.Balance.USD(), acct.LedgerSum.USD())
}
var rows int
if err := f.store.Pool().QueryRow(f.ctx,
`select count(*) from runs where book_id = $1`, book).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 0 {
t.Fatalf("%d run rows after a refused start, want none", rows)
}
// The control: the same call over the same book with its directory back takes the hold. Without
// it "no money moved" would be satisfied by a fixture that could not have spent any.
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}); err != nil {
t.Fatalf("the same start with the directory in place: %v", err)
}
if got := f.account(t); got.Reserved != fixtureHold(100) {
t.Fatalf("the control start reserved %s, want %s — this fixture never had money at stake",
got.Reserved.USD(), fixtureHold(100).USD())
}
}
// The same absence with the storage MARKER gone with it is the deployment's fault, and it must not
// be answered as the book's.
//
// ⛔ THE ASYMMETRY IS THE POINT, and the intake paid for it once already (PD-192): an unmounted
// volume leaves an empty mountpoint, so every book under it reads exactly like a book whose own
// directory was removed. Read as the book's own end there, one sweep rejected every book on the host
// with a reason that blames the user's file and has no way back. Here the price is smaller and the
// same in kind — a user told their book cannot be translated while the truth is that an operator has
// a volume to mount — so the answer is the deployment's, and `errors.Is(err, ErrBookNotReady)` must
// stay FALSE.
func TestAVanishedBooksVolumeIsTheDeploymentsFaultAndNotTheBooks(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
root, _ := onTheVolume(t, f, book)
before := f.account(t)
// The whole root, marker included — an unmount, not a deletion under it.
if err := os.RemoveAll(root); err != nil {
t.Fatal(err)
}
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)})
if !errors.Is(err, ErrStorageUnavailable) {
t.Fatalf("a start over an unmounted volume answered %v, want ErrStorageUnavailable", err)
}
if errors.Is(err, ErrBookNotReady) {
t.Errorf("the host's fault reached the user as the book's own: %v", err)
}
if acct := f.account(t); acct.Reserved != before.Reserved || acct.Balance != before.Balance {
t.Fatalf("a refused start moved money: %+v -> %+v", before, acct)
}
// And the mount coming back is all it takes: nothing about the book was written down, so the
// same call succeeds without anybody repairing anything.
if err := os.MkdirAll(filepath.Join(root, book), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, books.StorageMarker), []byte("test"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}); err != nil {
t.Fatalf("the same start after the volume came back: %v", err)
}
if got := f.account(t); got.Reserved != fixtureHold(100) {
t.Fatalf("the control start reserved %s, want %s — this fixture never had money at stake",
got.Reserved.USD(), fixtureHold(100).USD())
}
}
// The RESUME door is the second one money goes through, and it asks the same question.
//
// PD-162 is about a run that can never move, not about one handle: a resume re-opens the run, takes
// a hold of what is left of its budget and hands it to a spawn that will fail for exactly the same
// reason a first spawn would. Measured before the guard reached this path: the resume returned the
// run and the account's reserved figure went from nothing to the remainder of the budget, with no
// way back except an operator's `run abandon`.
func TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
_, dir := onTheVolume(t, f, book)
runID := f.stopped(t, 100, money.MicroUSD(500_000), runner.Marker{Result: "exit-code",
Code: "exited", Status: "1", At: f.now.Add(time.Second)})
before := f.account(t)
if before.Reserved != 0 {
t.Fatalf("the stopped run still holds %s: this test cannot tell a new hold from an old one",
before.Reserved.USD())
}
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
if _, err := f.svc.Resume(f.ctx, "u1", runID); !errors.Is(err, ErrSourceGone) {
t.Fatalf("a resume over a missing directory answered %v, want ErrSourceGone", err)
}
acct := f.account(t)
if acct.Reserved != before.Reserved || acct.Balance != before.Balance {
t.Fatalf("a refused resume moved money: %+v -> %+v", before, acct)
}
if acct.Balance != acct.LedgerSum {
t.Fatalf("the cached balance and the ledger disagree: %s vs %s", acct.Balance.USD(), acct.LedgerSum.USD())
}
// The control, as at the other door: with the directory back the same call re-opens the run and
// takes the hold, so "no money moved" above is a fact about the guard and not about the fixture.
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil {
t.Fatalf("the same resume with the directory in place: %v", err)
}
if got := f.account(t); got.Reserved != fixtureHold(100)-money.MicroUSD(500_000) {
t.Fatalf("the control resume reserved %s, want the remainder %s — this fixture never had money at stake",
got.Reserved.USD(), (fixtureHold(100) - money.MicroUSD(500_000)).USD())
}
}
// A book that does not live under the intake's root is never declared finished on the strength of a
// marker about somebody else's volume.
//
// ⛔ THE MARKER IS EVIDENCE ABOUT ONE ROOT AND NOTHING ELSE. `tmplatformctl book add --workdir` puts a
// book on any absolute path, and an instance with no `BooksDir` at all holds every book that way; for
// those, the intake's healthy marker says nothing whatsoever about the volume the book is on. Read as
// evidence anyway, a vanished mount under such a book would be announced as that book's own end —
// PD-192 with a longer path, and exactly the mistake this pair of errors exists to avoid. So where
// there is no sentinel to ask, the answer is the deployment's.
//
// Found by the pack's own adversarial pass, which probed the shape the tests did not have.
func TestABookOutsideTheIntakesRootIsNotDeclaredDeadByAnotherVolumesMarker(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
// A marked, healthy intake root — and a book that is NOT under it.
onTheVolume(t, f, book)
elsewhere := filepath.Join(t.TempDir(), "hand-placed")
if err := os.MkdirAll(elsewhere, 0o700); err != nil {
t.Fatal(err)
}
if _, err := f.store.Pool().Exec(f.ctx,
`update books set workdir = $2 where id = $1`, book, elsewhere); err != nil {
t.Fatal(err)
}
if !books.StorageIsThere(f.svc.Cfg.BooksDir) {
t.Fatal("the intake root lost its marker: this test would then measure the ordinary unmount")
}
if err := os.RemoveAll(elsewhere); err != nil {
t.Fatal(err)
}
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)})
if !errors.Is(err, ErrStorageUnavailable) {
t.Fatalf("a start over a book outside the root answered %v, want the deployment's ErrStorageUnavailable", err)
}
if errors.Is(err, ErrBookNotReady) {
t.Errorf("a marker about ANOTHER volume was read as proof that this book is finished: %v", err)
}
if acct := f.account(t); acct.Reserved != 0 {
t.Errorf("a refused start reserved %s", acct.Reserved.USD())
}
}
// A FILE where the book's project directory should be is not a project directory, and `os.Stat`
// alone cannot tell the difference.
//
// The engine opens a directory; a path that answers `Stat` without being one would pass the door,
// take the money and die in the transient unit — the shape this whole guard exists to prevent. It is
// the deployment's word and not the book's: this platform did not write that file, and it is not
// something to announce as the end of somebody's book.
func TestAFileWhereTheBooksDirectoryShouldBeIsRefusedToo(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
_, dir := onTheVolume(t, f, book)
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dir, []byte("not a project"), 0o600); err != nil {
t.Fatal(err)
}
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)})
if !errors.Is(err, ErrStorageUnavailable) {
t.Fatalf("a start over a FILE in the book's place answered %v, want ErrStorageUnavailable", err)
}
if acct := f.account(t); acct.Reserved != 0 {
t.Errorf("a refused start reserved %s", acct.Reserved.USD())
}
}
// A directory that is THERE and cannot be read is the host's business too — and the refusal carries
// no path.
//
// Two properties in one test because they are one decision. The error class: an unreadable directory
// is not the book's end (nothing about the book is wrong) and not an internal error (nothing is
// broken here), so it travels as the deployment's, like an unmounted volume. And the TEXT: `os.Stat`
// answers a *fs.PathError whose message carries the book's own directory, this error reaches an ERROR
// log through the handler, and a user's book has no business being in one (PD-139, PD-99). The errno
// is what an operator needs; the path is what the run id already resolves for anyone allowed to ask.
func TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
root, dir := onTheVolume(t, f, book)
// The book's directory stays; what goes is the right to look into the root.
if err := os.Chmod(root, 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(root, 0o700) })
if _, err := os.Stat(dir); !errors.Is(err, fs.ErrPermission) {
// A host where this cannot be arranged — running as root, or a filesystem that ignores the
// mode — would otherwise measure the ENOENT branch and report it as this one.
t.Skipf("this host still reads a 0000 directory (%v): the unreadable-directory branch is not exercised", err)
}
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)})
if !errors.Is(err, ErrStorageUnavailable) {
t.Fatalf("a start over an unreadable directory answered %v, want ErrStorageUnavailable", err)
}
if errors.Is(err, ErrBookNotReady) {
t.Errorf("the host's fault reached the user as the book's own: %v", err)
}
if strings.Contains(err.Error(), dir) || strings.Contains(err.Error(), book) {
t.Errorf("the refusal carries the book's directory into a line that is logged at ERROR: %q", err)
}
// It still says WHAT happened: the operation and the errno, which is what an operator acts on.
if !strings.Contains(err.Error(), "stat") || !errors.Is(err, fs.ErrPermission) {
t.Errorf("the refusal says nothing an operator can act on: %q", err)
}
if acct := f.account(t); acct.Reserved != 0 {
t.Errorf("a refused start reserved %s", acct.Reserved.USD())
}
}
// The order form asks the DOOR'S OWN question and says so before the click.
//
// PD-455: the verdict was computed from what is left of the book and what the balance covers, and
// nothing on that path asked whether the book already had a run. A second purchase is refused by the
// door, so the form answered `covers_all` over a book whose click could only produce an error — and
// the facts to say so were a query away.
//
// The pin is the PAIR: the form's answer and the door's are taken from one predicate, so it asserts
// both, and the door's refusal is the one the form predicted. Money stays what it is — the balance
// really does cover this book — because the verdict answers about money and this is not about money.
func TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning(t *testing.T) {
f := newFixture(t, "10", 4)
book := f.bookID(t)
// Before anything runs the form promises a start, and nothing stands in its way.
clear, err := f.svc.Order(f.ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if clear.Refusal != nil {
t.Fatalf("the form refuses a book at rest: %v", clear.Refusal)
}
if clear.Verdict != pricing.VerdictCoversAll {
t.Fatalf("the fixture's balance does not cover its own book (%q): this test cannot say what it claims",
clear.Verdict)
}
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(1)}); err != nil {
t.Fatal(err)
}
opts, err := f.svc.Order(f.ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if !errors.Is(opts.Refusal, pgstore.ErrRunInFlight) {
t.Fatalf("the form says %v over a book that is being translated, want the door's run_in_flight", opts.Refusal)
}
// The money half is untouched, and that is deliberate: `verdict` answers "does the balance cover
// it", the balance does, and a fourth verdict value would break every generated client.
if opts.Verdict != pricing.VerdictCoversAll {
t.Errorf("the verdict moved to %q because a run is going; it answers about MONEY", opts.Verdict)
}
// …and the door answers exactly what the form predicted.
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(1)}); !errors.Is(err, pgstore.ErrRunInFlight) {
t.Fatalf("the door answered %v where the form predicted run_in_flight", err)
}
// A SECOND book of the same account is not blocked by the first one's run: the predicate is about
// this book, and a form that answered otherwise would send the user to stop a run for nothing.
if second, err := f.svc.Order(f.ctx, "u1", f.secondBook(t)); err != nil {
t.Fatal(err)
} else if second.Refusal != nil {
t.Errorf("another book's run refuses this book's form: %v", second.Refusal)
}
}