362 lines
16 KiB
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)
|
|
}
|
|
}
|