initial commit
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
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
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
105
internal/app/backup.go
Normal file
105
internal/app/backup.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue