146 lines
7.1 KiB
Go
146 lines
7.1 KiB
Go
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 `<dir>/backups/<stamp>.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")
|
|
}
|