Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
105 lines
3.7 KiB
Go
105 lines
3.7 KiB
Go
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
|
|
}
|