textmachine/platform/cmd/tmplatformctl/backup.go

195 lines
7.3 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"text/tabwriter"
"time"
"textmachine/platform/internal/backup"
"textmachine/platform/internal/config"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/runner"
)
// backup.go: the operator's handle on restore points — take one now, see what there is, check that
// what there is has not rotted.
//
// ⚠ THERE IS NO `restore` COMMAND HERE, AND ITS ABSENCE IS A DECISION. A restore replaces a live
// database and a live library, on the worst day this deployment has, from a copy whose completeness
// only a human can judge; a one-word command for that is a one-word command for destroying the
// current state by mistake. The procedure is written out step by step in deploy/README.md and every
// step is a standard tool the operator already knows (`pg_restore`, `cp`) — and the restore point
// carries those steps INSIDE itself, in the manifest's notes, so they survive travelling away from
// this repository.
//
// The daemon takes points on its own schedule. This command exists for the two moments a schedule
// cannot cover: the deliberate one before a migration or an upgrade, and the anxious one after an
// incident, when the question is "what have we actually got".
// backupCmd is `tmplatformctl backup`.
func backupCmd(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("backup", flag.ContinueOnError)
list := fs.Bool("list", false, "list the restore points and say nothing else")
verify := fs.String("verify", "", "re-hash one restore point (its stamp, or `latest`) against its manifest")
// --dir is what makes the read-only halves usable on a bad day: `--list` and `--verify` must
// work on a host whose environment is broken or whose deployment is gone entirely — a restore
// point copied onto a laptop is still a restore point — so they never require config.Load().
dir := fs.String("dir", "", "the backup directory (default: TM_PLATFORM_BACKUP_DIR)")
if err := fs.Parse(args); err != nil {
return err
}
if *list || *verify != "" {
root, err := backupDir(*dir)
if err != nil {
return err
}
if *verify != "" {
return verifyPoint(root, *verify, out)
}
return listPoints(root, out)
}
return takePoint(ctx, out)
}
// backupDir resolves where the points are, preferring the operator's own word.
func backupDir(flagValue string) (string, error) {
if flagValue != "" {
return flagValue, nil
}
// Read directly and not through config.Load: this path has to answer on a host whose environment
// is half-configured, which is exactly the host somebody is standing on when they ask.
if v := os.Getenv("TM_PLATFORM_BACKUP_DIR"); v != "" {
return v, nil
}
return "", fmt.Errorf("no backup directory: set TM_PLATFORM_BACKUP_DIR or pass --dir")
}
// listPoints prints what there is, newest first, with each point's own verdict about itself.
func listPoints(root string, out io.Writer) error {
points, err := backup.List(root)
if err != nil {
return err
}
if len(points) == 0 {
_, _ = fmt.Fprintf(out, "no restore points in %s\n", root)
return nil
}
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "STAMP\tTAKEN\tBOOKS\tSKIPPED\tCOMPLETE\tBYTES")
for i := len(points) - 1; i >= 0; i-- {
p := filepath.Join(root, points[i])
m, err := backup.ReadManifest(p)
if err != nil {
// A point whose manifest cannot be read is exactly what this listing is for; it is
// printed as a row rather than ending the listing of the ones that are fine.
_, _ = fmt.Fprintf(w, "%s\tUNREADABLE\t\t\t\t%v\n", points[i], err)
continue
}
var bytes int64
copied, skipped := 0, 0
bytes += m.Postgres.Bytes
for _, b := range m.Books {
if b.Skipped != "" {
skipped++
continue
}
copied++
for _, f := range b.Files {
bytes += f.Bytes
}
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%t\t%d\n", m.Stamp,
m.TakenAt.UTC().Format(time.RFC3339), copied, skipped, m.Complete, bytes)
}
return w.Flush()
}
// verifyPoint re-reads a point and compares every file with the digest its manifest recorded.
//
// ⚠ This is the command that turns a manifest into a guarantee. Size alone cannot tell a good copy
// from one a failing disk rewrote or a full filesystem truncated, and the moment to find that out
// is not the moment you need the copy.
func verifyPoint(root, which string, out io.Writer) error {
stamp := which
if which == "latest" {
points, err := backup.List(root)
if err != nil {
return err
}
if len(points) == 0 {
return fmt.Errorf("no restore points in %s", root)
}
stamp = points[len(points)-1]
}
point := filepath.Join(root, stamp)
problems, err := backup.Verify(point)
if err != nil {
return err
}
if len(problems) == 0 {
_, _ = fmt.Fprintf(out, "%s: every file matches its manifest\n", stamp)
return nil
}
for _, p := range problems {
_, _ = fmt.Fprintf(out, "%s: %s\n", stamp, p)
}
return fmt.Errorf("%s: %d problem(s) — this restore point is not sound", stamp, len(problems))
}
// takePoint makes one now.
//
// It loads the WHOLE configuration rather than a few variables, and that is deliberate: a restore
// point taken by hand must be the same object the daemon takes, made from the same settings. Two
// readers of one environment is how the deployment and the tool come to disagree about where the
// books are.
func takePoint(ctx context.Context, out io.Writer) error {
cfg, err := config.Load()
if err != nil {
return err
}
if !cfg.BackupEnabled() {
return fmt.Errorf("TM_PLATFORM_BACKUP_DIR is not set: this deployment keeps no restore points")
}
if cfg.DSN == "" {
return fmt.Errorf("TM_PLATFORM_DSN (or TM_PLATFORM_DSN_FILE) is not set: a restore point without the credit ledger is not one")
}
// ⚠ SAID BEFORE THE WORK, not discovered in the summary afterwards. An instance with no engine
// binary cannot ask for a consistent copy of ANY book, so what it produces is a point holding the
// ledger and nothing else. That is a legitimate thing to want on a read replica — and a very bad
// thing to be handed unannounced by somebody taking "a restore point before the migration".
if cfg.Runner.EngineBinary == "" {
_, _ = fmt.Fprintf(out, "⚠ TM_PLATFORM_ENGINE_BIN is not set: this instance cannot copy any book's database, "+
"so the point will carry the credit ledger ONLY and will be marked incomplete\n")
}
store, err := pgstore.Open(ctx, cfg.DSN)
if err != nil {
return err
}
defer store.Close()
svc := &backup.Service{
Cfg: backup.Config{
Dir: cfg.Backup.Dir, Every: cfg.Backup.Every, Keep: cfg.Backup.Keep,
PgDumpBin: cfg.Backup.PgDumpBin, PgRestoreBin: cfg.Backup.PgRestoreBin,
DSN: cfg.DSN, EngineBinary: cfg.Runner.EngineBinary,
},
Store: store, Engine: runner.New(slog.New(slog.NewTextHandler(os.Stderr, nil))),
}
res, err := svc.Take(ctx)
if err != nil {
return err
}
_, _ = fmt.Fprintf(out, "restore point %s: %d book(s), %d skipped, %d bytes, complete=%t\n%s\n",
res.Stamp, res.Books, res.Skipped, res.Bytes, res.Complete, res.Path)
if !res.Complete {
// Not an error — the point is real and worth having — but it must not read as an unqualified
// success either: something in it is missing and the manifest says which.
_, _ = fmt.Fprintf(out, "⚠ at least one book was not copied; see %s in the manifest\n", backup.ManifestFile)
}
return nil
}