go-bananas/internal/app/backup.go

106 lines
3.7 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:11:35 +02:00
package app
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
)
// RunBackup is the `go-bananas backup` subcommand: it writes a consistent copy of
// the SQLite database and exits.
//
// It exists as a subcommand rather than as a separate tool, or as a shell script
// copying the file, for two reasons.
//
// The first is correctness. `cp` on a live database produces a torn copy, and the
// result can open without complaint while being corrupt. SQLite's VACUUM INTO
// reads through the database's own backup machinery and produces a consistent
// snapshot while the service keeps serving.
//
// The second is packaging. The alternative is a container that ships a `sqlite3`
// binary, which means either a third-party image or installing a package into a
// container whose root filesystem is deliberately read-only. The SQLite library is
// already linked into this binary, at exactly the version the schema was written
// for, so using it removes both problems.
//
// The Helm chart's snapshot CronJob runs this command. A deployment that does not
// use the chart can run it from anywhere the binary is.
func RunBackup(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("backup", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var (
destination = fs.String("to", "", "Path to write the snapshot to. Required.")
database = fs.String("database", "", "Path to the SQLite database. Defaults to DATABASE_PATH.")
timeout = fs.Duration("timeout", 5*time.Minute, "Give up after this long.")
)
fs.Usage = func() {
fmt.Fprintf(os.Stderr,
"usage: go-bananas backup --to <path> [--database <path>]\n\n"+
"Writes a consistent snapshot of the database using SQLite's online backup.\n"+
"It is safe to run against a database that is being written to.\n\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return fmt.Errorf("%w: %w", domain.ErrInvalid, err)
}
if *destination == "" {
fs.Usage()
return fmt.Errorf("%w: --to is required", domain.ErrInvalid)
}
if fs.NArg() > 0 {
return fmt.Errorf("%w: unexpected argument %q", domain.ErrInvalid, fs.Arg(0))
}
// The database path comes from the same environment variable the server reads,
// so a CronJob and the service cannot disagree about which file is being
// snapshotted. DATABASE_PATH alone is enough: the rest of the configuration is
// irrelevant to a backup, and requiring it would mean a snapshot job failing
// because an unrelated setting is absent.
path := *database
if path == "" {
path = os.Getenv("DATABASE_PATH")
}
if path == "" {
path = config.Defaults().DatabasePath
}
if !store.IsPersistentPath(path) {
return fmt.Errorf("%w: cannot snapshot an ephemeral database (%s); it holds no data once the process exits",
domain.ErrInvalid, path)
}
// Bounded, because a snapshot that hangs holds the CronJob's slot forever and a
// snapshot of a very large database is genuinely slow.
ctx, cancel := context.WithTimeout(ctx, *timeout)
defer cancel()
st, err := store.Open(ctx, store.Options{Path: path})
if err != nil {
return fmt.Errorf("open database for snapshot: %w", err)
}
defer func() { _ = st.Close() }()
target := *destination
if !filepath.IsAbs(target) {
// Made absolute so the log line says where the file actually is, rather than
// leaving the reader to resolve it against whatever the working directory was.
abs, absErr := filepath.Abs(target)
if absErr == nil {
target = abs
}
}
if err := st.Snapshot(ctx, target); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "snapshot written to %s\n", target)
return nil
}