- Rename dist/ -> deploy/ with subdirs examples/, scripts/, systemd/ per standard repository layout - Update .gitignore: gitignore all of dist/ (build output only) - Makefile: all target is now vet->lint->test->build; add vet, proto-lint, devserver targets; CGO_ENABLED=0 for builds (modernc.org/sqlite is pure-Go, no C toolchain needed); CGO_ENABLED=1 retained for tests (race detector) - Dockerfile: builder -> golang:1.26-alpine, runtime -> alpine:3.21; drop libc6 dep; add /srv/mcias/certs and /srv/mcias/backups to image - deploy/systemd/mcias.service: add RestrictSUIDSGID=true - deploy/systemd/mcias-backup.service: new oneshot backup unit - deploy/systemd/mcias-backup.timer: daily 02:00 UTC, 5m jitter - deploy/scripts/install.sh: install backup units and enable timer; create certs/ and backups/ subdirs in /srv/mcias - buf.yaml: add proto linting config for proto-lint target - internal/db: add Snapshot and SnapshotDir methods (VACUUM INTO) - cmd/mciasdb: add snapshot subcommand; no master key required
69 lines
2.0 KiB
Go
69 lines
2.0 KiB
Go
package db
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Snapshot creates a consistent backup of the database at destPath using
|
|
// SQLite's VACUUM INTO statement. VACUUM INTO acquires a read lock for the
|
|
// duration of the copy, which is safe while the server is running in WAL mode.
|
|
// The destination file is created by SQLite; the caller must ensure the parent
|
|
// directory exists.
|
|
func (db *DB) Snapshot(destPath string) error {
|
|
// VACUUM INTO is not supported on in-memory databases.
|
|
if strings.Contains(db.path, "mode=memory") {
|
|
return fmt.Errorf("db: snapshot not supported on in-memory databases")
|
|
}
|
|
if _, err := db.sql.Exec("VACUUM INTO ?", destPath); err != nil {
|
|
return fmt.Errorf("db: snapshot VACUUM INTO %q: %w", destPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SnapshotDir creates a timestamped backup in dir and prunes backups older
|
|
// than retainDays days. dir is created with mode 0750 if it does not exist.
|
|
// The backup filename format is mcias-20060102-150405.db.
|
|
func (db *DB) SnapshotDir(dir string, retainDays int) (string, error) {
|
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
|
return "", fmt.Errorf("db: create backup dir %q: %w", dir, err)
|
|
}
|
|
|
|
ts := time.Now().UTC().Format("20060102-150405")
|
|
dest := filepath.Join(dir, fmt.Sprintf("mcias-%s.db", ts))
|
|
if err := db.Snapshot(dest); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Prune backups older than retainDays.
|
|
if retainDays > 0 {
|
|
cutoff := time.Now().UTC().AddDate(0, 0, -retainDays)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
// Non-fatal: the backup was written; log pruning failure separately.
|
|
return dest, fmt.Errorf("db: list backup dir for pruning: %w", err)
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
|
|
continue
|
|
}
|
|
// Skip the file we just wrote.
|
|
if e.Name() == filepath.Base(dest) {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if info.ModTime().Before(cutoff) {
|
|
_ = os.Remove(filepath.Join(dir, e.Name()))
|
|
}
|
|
}
|
|
}
|
|
|
|
return dest, nil
|
|
}
|