195 lines
7.3 KiB
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
|
|
}
|