// Package dbsnap makes the hub's nightly database snapshot (R-173, hub v0.136.0, `05` §16.3, // runbooks/RUNBOOK-hub-db-offsite-backup.md Step 3). // // Every night the hub writes `/snapshots/hub-.db` with SQLite's VACUUM INTO — one consistent point // in time, WAL-aware — then keeps the newest Keep (2). DooPlex picks up the newest one, checks it, encrypts it and // pushes it to ep0 (Step 4). The hub never ships anything itself: it has no ep0 credential. // // Written as `.tmp` and renamed, so a reader never sees half a file; a run never overlaps another (a second call // returns ErrBusy). Pinned by dbsnap_test.go. package dbsnap import ( "context" "errors" "fmt" "log" "os" "path/filepath" "sort" "strings" "sync" "time" ) // Keep is how many snapshots stay on the volume (the newest). Decided by CC — operator may reverse (the volume was // grown to 2 GiB for it, operator choice 2026-10-05). const Keep = 2 // Prefix and Suffix make a snapshot's file name: hub-20261005T020000Z.db. const ( Prefix = "hub-" Suffix = ".db" ) // ErrBusy is returned when a snapshot is already being written. var ErrBusy = errors.New("dbsnap: a snapshot is already being written") // Snapshotter is the store's VACUUM INTO. type Snapshotter interface { SnapshotInto(ctx context.Context, path string) error } // Maker writes and prunes snapshots in Dir. type Maker struct { Store Snapshotter Dir string Logger *log.Logger Now func() time.Time mu sync.Mutex running bool } // Result is one snapshot written. type Result struct { Name string `json:"name"` Bytes int64 `json:"bytes"` Duration time.Duration `json:"duration_ns"` Pruned []string `json:"pruned,omitempty"` } // Make writes one snapshot and prunes to Keep. Safe to call from the nightly job and the operator button at once. func (m *Maker) Make(ctx context.Context) (Result, error) { m.mu.Lock() if m.running { m.mu.Unlock() return Result{}, ErrBusy } m.running = true m.mu.Unlock() defer func() { m.mu.Lock(); m.running = false; m.mu.Unlock() }() now := time.Now if m.Now != nil { now = m.Now } if err := os.MkdirAll(m.Dir, 0o700); err != nil { return Result{}, fmt.Errorf("dbsnap: %w", err) } name := Prefix + now().UTC().Format("20060102T150405Z") + Suffix final := filepath.Join(m.Dir, name) tmp := final + ".tmp" _ = os.Remove(tmp) // a leftover from a crash mid-write start := time.Now() if err := m.Store.SnapshotInto(ctx, tmp); err != nil { _ = os.Remove(tmp) return Result{}, err } if err := os.Chmod(tmp, 0o600); err != nil { _ = os.Remove(tmp) return Result{}, fmt.Errorf("dbsnap: chmod: %w", err) } if err := os.Rename(tmp, final); err != nil { _ = os.Remove(tmp) return Result{}, fmt.Errorf("dbsnap: rename: %w", err) } fi, err := os.Stat(final) if err != nil { return Result{}, fmt.Errorf("dbsnap: stat: %w", err) } res := Result{Name: name, Bytes: fi.Size(), Duration: time.Since(start)} res.Pruned = m.prune() if m.Logger != nil { m.Logger.Printf("[INFO] db snapshot written: %s (%d bytes, %s); pruned %d, keeping %d", name, res.Bytes, res.Duration.Round(time.Millisecond), len(res.Pruned), Keep) } return res, nil } // List returns the snapshot names in Dir, oldest first (the stamp sorts as text). func List(dir string) []string { entries, _ := os.ReadDir(dir) var out []string for _, e := range entries { n := e.Name() if !e.IsDir() && strings.HasPrefix(n, Prefix) && strings.HasSuffix(n, Suffix) { out = append(out, n) } } sort.Strings(out) return out } func (m *Maker) prune() []string { names := List(m.Dir) var pruned []string for len(names) > Keep { if err := os.Remove(filepath.Join(m.Dir, names[0])); err != nil && m.Logger != nil { m.Logger.Printf("[WARN] db snapshot prune %s: %v", names[0], err) } pruned = append(pruned, names[0]) names = names[1:] } return pruned } // NeedsCatchUp reports whether the newest snapshot in dir is missing or older than maxAge — the hub runs one at // start-up then, so a pod that was down at 02:00 does not leave DooPlex pushing a two-day-old copy. func NeedsCatchUp(dir string, now time.Time, maxAge time.Duration) bool { names := List(dir) if len(names) == 0 { return true } stamp := strings.TrimSuffix(strings.TrimPrefix(names[len(names)-1], Prefix), Suffix) t, err := time.Parse("20060102T150405Z", stamp) if err != nil { return true } return now.Sub(t) > maxAge }