← repoman internals

internal/backup

import "git.griefed.de/griefed/repoman/internal/backup"
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.