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 [--database ]\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 }