d4be9f6ff1
gates / gates (push) Failing after 14m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
151 lines
4.4 KiB
Go
151 lines
4.4 KiB
Go
// 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 `<data>/snapshots/hub-<UTC stamp>.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 `<name>.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
|
|
}
|