package backup // import "git.griefed.de/griefed/repoman/internal/backup"
Package backup creates consistent snapshots of the SQLite database.
It exists so the snapshot logic has a single home shared by the CLI (`repoman
backup`) and the web UI ("Download backup" on the settings page) instead
of living inline in one command. The mechanism is SQLite's VACUUM INTO,
the recommended online-backup method for modern SQLite: it produces a
transactionally-consistent copy of the current database without holding an
exclusive lock for the whole copy, so the daemon can keep serving while it runs.
CONSTANTS
const (
// StagingRestoreDir holds an uploaded-but-not-yet-confirmed restore. The
// daemon ignores it on startup; only a confirmed (armed) restore applies.
StagingRestoreDir = "restore-staging"
// PendingRestoreDir holds a CONFIRMED restore. ApplyPendingRestore consumes
// it on the next startup. The handler arms a restore by renaming the
// staging dir to this one (an atomic rename within the data dir).
PendingRestoreDir = "restore-pending"
// RestoreDBFile / RestorePepperFile are the file names inside the staging
// and pending dirs. The pepper is optional (a same-instance rollback keeps
// the current pepper).
RestoreDBFile = "database.db"
RestorePepperFile = "pepper"
)
Restore staging layout, shared by the web handler (which stages an upload)
and ApplyPendingRestore (which the daemon runs at startup). Keeping the
names in one place stops the two halves from drifting.
FUNCTIONS
func ApplyPendingRestore(dataDir, dbPath, pepperPath string, now time.Time) (bool, error)
ApplyPendingRestore applies a confirmed restore staged under
<dataDir>/restore-pending, if one exists, BEFORE the database is opened. It:
1. copies the current DB (and any -wal/-shm sidecars) and pepper into a
timestamped <dataDir>/restore-rollback-<ts> directory, so a bad restore
can be undone by hand;
2. replaces dbPath with the staged database (clearing stale WAL/SHM);
3. replaces the pepper only if the staged restore included one;
4. removes the pending dir.
It returns whether a restore was applied. On any error it returns the
error and the caller should refuse to boot (a half-applied restore must not
serve).
func Create(ctx context.Context, conn Execer, destPath string) error
Create writes a consistent snapshot of the database reachable through conn
to destPath using SQLite's VACUUM INTO. It is safe to call while the daemon
is serving: VACUUM INTO takes only brief locks at the start and end of the
copy, not for its whole duration.
destPath must either not exist or be an existing empty file — SQLite's
VACUUM INTO refuses to overwrite a non-empty file, which is the backstop
against clobbering an unrelated file. The path is spliced into the SQL
because VACUUM INTO does not accept a bind parameter for it; single quotes
are doubled first (see escapeSQLiteLiteral).
func FileName(at time.Time) string
FileName returns the backup file name for the given instant (UTC).
func IsBackupName(name string) bool
IsBackupName reports whether name is a well-formed backup file name.
Used by the download/delete handlers to reject anything that is not one of
ours (defence in depth against path traversal alongside filepath.Base).
func ParseFileTime(name string) (time.Time, bool)
ParseFileTime extracts the UTC backup instant from a backup file name,
reporting ok=false for any name not matching the scheme.
func Prune(dir string, policy GFSPolicy) ([]string, error)
Prune deletes backups in dir not retained by the policy and returns the
names it removed. When the policy retains nothing (all tiers 0) it is a
no-op, keeping every backup rather than deleting them all. Individual delete
failures are skipped (not fatal) so one stuck file cannot block the rest.
TYPES
type Execer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
Execer is the slice of database behaviour Create needs: executing a single
statement. Both *sql.DB and *sql.Tx satisfy it, so a caller can pass either
a connection pool or an in-progress transaction.
type GFSPolicy struct {
KeepDaily int
KeepWeekly int
KeepMonthly int
}
GFSPolicy is a grandfather-father-son retention policy: keep the most recent
KeepDaily days, KeepWeekly ISO weeks, and KeepMonthly months of backups. A
backup is retained if it is the most recent backup within any retained day,
week, or month bucket. A tier count of 0 disables that tier.
type Info struct {
Name string // base file name, e.g. repoman-backup-20260613-030000.db
ModTime time.Time // UTC time encoded in the name (the backup instant)
Size int64 // file size in bytes
}
Info describes one stored backup file.
func CreateInDir(ctx context.Context, conn Execer, dir string, at time.Time) (Info, error)
CreateInDir writes a consistent snapshot into dir with a timestamped name,
creating dir (0700) if missing, and returns the new file's Info.
It delegates the snapshot to Create (VACUUM INTO), so a scheduled backup is
byte-identical to a CLI or download backup.
func List(dir string) ([]Info, error)
List returns the backups in dir, newest first. A missing directory yields
an empty list (no backups taken yet), not an error. Files not matching the
backup scheme are ignored.
type ScheduleConfig struct {
Enabled bool
Hour int
Minute int
Policy GFSPolicy
}
ScheduleConfig is the current backup schedule, supplied to the Scheduler
on every tick so operator changes (saved in global settings) take effect
without a restart. Hour/Minute are the UTC daily-backup time.
type Scheduler struct {
Conn Execer
Dir string
Load func(context.Context) ScheduleConfig // reads current settings each tick
Interval time.Duration // poll interval; default 1 minute
Now func() time.Time // injectable clock; default time.Now
// OnResult, if set, is called after each backup attempt (success or
// failure) for audit/logging. Optional.
OnResult func(ctx context.Context, info Info, err error)
}
Scheduler runs one database backup per day at a configured UTC time,
then prunes old backups under the GFS policy. It polls on a fixed interval
rather than firing on an exact cron tick, which gives free catch-up:
if the daemon was down at the scheduled time, the first tick after it comes
back (still on the same day, past the time, with no backup yet taken today)
runs the missed backup.
func (s *Scheduler) Run(ctx context.Context)
Run polls until ctx is cancelled, taking a backup whenever one is due.