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 }