package runner import ( "bytes" "context" "errors" "fmt" "os/exec" "path/filepath" "strings" "syscall" "time" ) // backup.go: the restore-point channel — `tmctl backup` run as a direct child, like the other $0 // commands (engine.go, build.go, bankapply.go). // // ⚠ WHY THE ENGINE MAKES THE COPY AND NOT THIS PLATFORM. A book's paid work lives in the engine's // SQLite, and D39.85 forbids this side to open it. That is not only a boundary rule here, it is // also the correct engineering: a live SQLite copied byte-wise while a run is writing it is a // silently torn file, and "we have a backup" would then be a belief rather than a mechanism. The // engine's verb runs `PRAGMA integrity_check` and then `VACUUM INTO`, which is SQLite's own // consistent-snapshot statement (backend/internal/store/backup.go, BackupSQLite) — so what this // platform copies is a file nobody is writing. // // ⚠ THE PATH IS READ OFF THE VERB'S OWN OUTPUT AND NEVER DERIVED. The engine writes its restore // point into a directory of its own choosing (`backups/` beside the project database) and takes no // flag for it, so the one lawful way to learn where it landed is to read what the verb printed: // deriving `/backups/.db` here would be this side re-implementing another zone's // convention, which 17-seam-inbound-law п.1 forbids and which artifacts.go already had to undo once. // // ⚠ AND THAT IS A DEBT, NAMED RATHER THAN HIDDEN: the line is prose, not a versioned document, so it // is the one channel of this seam that carries no version (17-seam-inbound-law п.3). The durable // cure is an engine-side `--out` or `--json` on `backup`; until then this parse is deliberately // STRICT — a line it does not recognise is an error and never a guess — and it is pinned against the // real binary rather than against a string this file made up (backup_live_test.go). // BackupArgs is the verb's argv. $0 and key-less: it reads the project database and writes a copy, // no provider is called, so no `--keys-file` (D20.4). func BackupArgs(workdir string) []string { return []string{"backup", "--config", filepath.Join(workdir, ConfigFile)} } // maxBackupOutput bounds what is read back. The verb prints ONE line; the cap refuses a process at // the configured path that is not the engine. const maxBackupOutput = 1 << 20 // backupStopGrace is how long the verb gets after SIGTERM before it is killed outright. // // It is the same 30 s as the build door's and for a nearer reason: `VACUUM INTO` writes a whole // database file, and a kill in the middle of one leaves a PARTIAL file behind, in the engine's own // `backups/` directory, under a name this platform never learned — so nothing here or there ever // removes it. (⚠ An earlier edition said such a leftover would block the next backup of that book: // it would not. The engine stamps to the second and refuses only a collision with that same second, // which the next pass an interval later never has.) The grace is what gives a nearly-done vacuum the // chance to finish and leave a whole file instead. const backupStopGrace = 30 * time.Second // backupLinePrefix and backupLineSuffix bracket the path in the verb's success line // (backend/cmd/tmctl/backup.go, backupCmd: `backup OK: %s (integrity_check green, VACUUM INTO)`). // Both halves are matched, so a line that merely begins the same way is not read as a path. const ( backupLinePrefix = "backup OK: " backupLineSuffix = " (integrity_check green, VACUUM INTO)" ) // BackupOutcome is what running the verb produced. type BackupOutcome struct { // Path is the restore point the engine wrote, as the engine named it. Empty when the verb did // not report one. Path string // ExitCode is the engine's own exit, valid when Exited. ExitCode int Exited bool // Stderr is the first line of the engine's stderr — for the operator's log, never for the wire. Stderr string } // Backup asks the engine for a consistent copy of a book's project database and returns where it // put it. // // A non-zero exit is NOT an error here, for the same reason it is not one in BankApply: it is an // answer, and the caller has to be able to read it. // // ⚠ BUT THE CODE ITSELF SAYS NOTHING, and the caller must not pretend otherwise. `tmctl backup` // classifies none of its refusals: "no database yet", a red `PRAGMA integrity_check`, a collision // with an existing restore point and a failed `VACUUM INTO` all return a plain error and all leave // through the default arm as exit **1** (backend/cmd/tmctl/backup.go, backend/cmd/tmctl/main.go) — // unlike `preflightBackup`, which does classify. So the exit code separates "the verb refused" from // "the verb worked" and nothing finer; deciding WHICH refusal it was belongs to the caller's own // facts about the book, never to this number and never to the message beside it. func (r *Runner) Backup(ctx context.Context, binary, workdir string) (BackupOutcome, error) { cmd := exec.CommandContext(ctx, binary, BackupArgs(workdir)...) cmd.Dir = workdir var out, errOut bytes.Buffer cmd.Stdout = &limitedBuffer{buf: &out, limit: maxBackupOutput} cmd.Stderr = &errOut cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = backupStopGrace err := cmd.Run() res := BackupOutcome{Stderr: string(firstLine(errOut.Bytes()))} if cmd.ProcessState != nil && cmd.ProcessState.Exited() { res.ExitCode, res.Exited = cmd.ProcessState.ExitCode(), true } if err != nil && !res.Exited { return res, errors.Join(fmt.Errorf("runner: tmctl backup: %w: %s", err, res.Stderr), ctx.Err()) } if res.Exited && res.ExitCode == 0 { path, perr := backupPathIn(out.String(), workdir) if perr != nil { // A zero exit whose output cannot be read is worse than a failure: the copy exists // somewhere and this side would report a success it cannot point at. return res, perr } res.Path = path } return res, nil } // backupPathIn finds the restore point in the verb's output and refuses anything else. // // The guard on the directory is not ceremony. This value becomes a source path that gets copied into // the deployment's backup store, so a line that named something outside the book's own directory // would make the parse of another zone's prose into a file-read primitive. Inside the workdir it can // only ever name the engine's own artifact. func backupPathIn(stdout, workdir string) (string, error) { for line := range strings.SplitSeq(stdout, "\n") { line = strings.TrimRight(line, "\r") if !strings.HasPrefix(line, backupLinePrefix) || !strings.HasSuffix(line, backupLineSuffix) { continue } path := line[len(backupLinePrefix) : len(line)-len(backupLineSuffix)] if path == "" { break } if !filepath.IsAbs(path) { path = filepath.Join(workdir, path) } clean := filepath.Clean(path) if rel, err := filepath.Rel(workdir, clean); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", fmt.Errorf("runner: tmctl backup reported a restore point outside the book's own directory: %q", clean) } return clean, nil } return "", errors.New("runner: tmctl backup exited 0 without naming the restore point it wrote; this build's output is not the one this platform can read") }