Files
Felis/internal/dbbackup/dbbackup.go

879 lines
29 KiB
Go

// Package dbbackup takes and restores logical backups of the control-plane
// PostgreSQL: users, passkeys, account links, server ownership, quotas, audit
// logs and the world_backups index that maps a world archive back to its owner.
// World archives live on their own volume (internal/archive); without this
// database they are files nobody can be matched to.
//
// A backup is one bundle, felis-db-<UTC stamp>-<label>.tar, holding
//
// MANIFEST.json what is in the bundle and each member's sha256
// db.dump pg_dump --format=custom of the felis database
// state/etc/felis/... the host state a rebuild needs: secrets.env
// (the DB password, session and forwarding
// secrets, registry tokens), felis.{host,pod}.toml,
// the panel TLS pair
// k8s/minecraftservers.json the MinecraftServer objects, when the cluster
// answered (best effort)
//
// plus a felis-db-....tar.sha256 sidecar in sha256sum format, so a copy shipped
// off the host can be checked with `sha256sum -c` before anyone relies on it.
//
// Bundles are written as a hidden .partial and renamed into place after an
// fsync, so a crash or a full disk leaves either a complete bundle or none.
// The dump is proven readable (pg_restore --list) before the bundle counts.
//
// Restore is all-or-nothing: the dump is replayed through psql in a single
// transaction that first drops everything the felis role owns, so a failure
// anywhere leaves the database exactly as it was, and objects a newer schema
// added do not survive to collide with the next `felis migrate up`.
package dbbackup
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
const (
// DefaultDir is where the host keeps its bundles. It is deliberately off the
// k3s storage tree: `rm -rf /var/lib/rancher` (a k3s reinstall) must not take
// the database backups with it.
DefaultDir = "/var/lib/felis/db-backups"
// DefaultStateDir is the host state directory bootstrap writes.
DefaultStateDir = "/etc/felis"
LabelDaily = "daily"
LabelPreMigrate = "pre-migrate"
LabelPreRestore = "pre-restore"
LabelManual = "manual"
// LabelOffsite is the bundle an off-site copy takes after copying world
// archives (internal/offsite), so the newest bundle off the machine lists
// them.
LabelOffsite = "offsite"
manifestEntry = "MANIFEST.json"
dumpEntry = "db.dump"
stateEntry = "state"
serversEntry = "k8s/minecraftservers.json"
bundlePrefix = "felis-db-"
bundleExt = ".tar"
sumExt = ".sha256"
stampLayout = "20060102T150405Z"
formatV1 = 1
)
// stateSkip are files in the state directory a bundle leaves out:
// bootstrap.done marks THIS host as installed, and carrying it to a fresh host
// would make the installer treat a first install as an upgrade.
var stateSkip = map[string]bool{"bootstrap.done": true}
var labelRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
// Tools names the PostgreSQL client binaries. Empty fields take the names on
// PATH; tests point them at fakes.
type Tools struct {
PGDump, PGRestore, PSQL string
// Exec, when set, is the argv prefix every tool runs under. The installer's
// database is a k3s Deployment and the host carries no PostgreSQL client, so
// the tools run in the database's own container:
// `k3s kubectl exec -i -n felis deploy/felis-postgres -c postgres --`.
// kubectl exec carries neither the environment nor files across: the dump
// comes back on stdout and archives go in on stdin, and the tools connect as
// Conn instead of the database URL.
Exec []string
// Conn is the libpq connection string the tools use under Exec: the
// container's own socket, which trusts local connections, so no password
// has to cross into it.
Conn string
}
func (t Tools) pgDump() string { return orDefault(t.PGDump, "pg_dump") }
func (t Tools) pgRestore() string { return orDefault(t.PGRestore, "pg_restore") }
func (t Tools) psql() string { return orDefault(t.PSQL, "psql") }
func orDefault(s, d string) string {
if s == "" {
return d
}
return s
}
// BackupOptions configures one backup.
type BackupOptions struct {
DatabaseURL string
Dir string // bundle directory; created 0700
Label string // daily | pre-migrate | pre-restore | manual | any [a-z0-9-]
// Keep is how many bundles of this label survive the post-backup prune;
// zero or less prunes nothing.
Keep int
StateDir string // host state to bundle; "" bundles none
Version string // felis build stamp, recorded in the manifest
Tools Tools
// ExportServers returns the cluster's MinecraftServer objects as JSON.
// When it fails the bundle is still written, with the reason in its
// manifest and in the Record, and Backup returns its path with
// ErrServersMissing: the database is what must not be lost, and a nightly
// run cannot hang on a cluster that happens to be down. A restore from
// such a bundle brings back no servers, so the caller has to make that
// heard.
ExportServers func(ctx context.Context) ([]byte, error)
// RequireServers makes an ExportServers failure fail the backup before a
// bundle is written, for a caller that can simply try again later.
RequireServers bool
// MetricsFile, when set, is rewritten after a successful backup with
// node-exporter textfile metrics (felis_db_backup_last_success_timestamp_seconds
// and felis_db_backup_last_size_bytes), which FelisDBBackupStale alerts on.
MetricsFile string
// Record stores a summary of the backup in platform_settings under
// StatusKey, which the admin panel reads to show how fresh the newest
// backup is. A failure to record is logged, not fatal.
Record bool
Now func() time.Time
Log io.Writer
}
// StatusKey is the platform_settings key Record writes; internal/api reads it.
const StatusKey = "db_backup_last"
// ErrServersMissing comes back from Backup, together with the path of the
// bundle it wrote, when the bundle holds the database but ExportServers
// failed every try: a restore from it brings back no servers.
var ErrServersMissing = errors.New("the bundle holds the database but not the MinecraftServer objects")
// StaleAfter is how old the newest daily backup may get before it counts as
// missed: a day plus the timer's randomized delay and a slow dump. `felis db
// check`, the watchdog, the admin panel and the FelisDBBackupStale alert
// (deploy/alerts) share it.
const StaleAfter = 26 * time.Hour
// Status is the value stored under StatusKey.
type Status struct {
At time.Time `json:"at"`
Name string `json:"name"`
Label string `json:"label"`
SizeBytes int64 `json:"size_bytes"`
FelisVersion string `json:"felis_version,omitempty"`
SchemaVersion int `json:"schema_version,omitempty"`
Dir string `json:"dir"`
// ServersError is why the bundle lacks the MinecraftServer objects, when
// it does.
ServersError string `json:"servers_error,omitempty"`
// DailyAt is when the newest daily bundle in Dir was written, as of this
// record: the timer's own freshness, which a manual, pre-migrate or
// off-site bundle taken since leaves as it was. Zero when Dir held none,
// and in records written before the field existed.
DailyAt time.Time `json:"daily_at,omitzero"`
}
// Manifest describes a bundle.
type Manifest struct {
Format int `json:"format"`
CreatedAt time.Time `json:"created_at"`
Label string `json:"label"`
FelisVersion string `json:"felis_version,omitempty"`
Database DatabaseInfo `json:"database"`
SchemaVersion int `json:"schema_version,omitempty"`
PGDumpVersion string `json:"pg_dump_version,omitempty"`
Files []ManifestEntry `json:"files"`
// Counts is nil in bundles taken before it was recorded, or when the
// database did not answer the count.
Counts *Counts `json:"counts,omitempty"`
// ServersError is why k8s/minecraftservers.json is absent, when it is.
ServersError string `json:"servers_error,omitempty"`
}
// Counts is how much the database held when the bundle was taken: accounts
// and servers, deleted ones left out.
type Counts struct {
Users int `json:"users"`
Servers int `json:"servers"`
}
// Fresh is whether the database looks like a new install's: no servers and
// at most the owner the first-run setup creates. A host rebuilt after a loss
// backs up (and syncs off-site) such a database before anyone restores onto
// it, so a restore that picks bundles by date alone would pick it.
func (c *Counts) Fresh() bool { return c != nil && c.Servers == 0 && c.Users <= 1 }
// String is what the CLI prints for the counts.
func (c *Counts) String() string {
if c == nil {
return "not recorded"
}
count := func(n int, what string) string {
if n == 1 {
return "1 " + what
}
return fmt.Sprintf("%d %ss", n, what)
}
return count(c.Users, "account") + ", " + count(c.Servers, "server")
}
// DatabaseInfo is the connection a bundle was taken from, password excluded.
type DatabaseInfo struct {
Host string `json:"host"`
Port string `json:"port,omitempty"`
Name string `json:"name"`
User string `json:"user"`
}
// ManifestEntry is one bundle member.
type ManifestEntry struct {
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256,omitempty"` // absent for symlinks
Mode uint32 `json:"mode"`
Link string `json:"link,omitempty"`
}
// Bundle is one bundle on disk.
type Bundle struct {
Name string
Path string
Label string
Created time.Time
Size int64
}
// conn splits a postgres:// URL into the URL libpq should see (password
// removed) and the password, which goes to the child through PGPASSWORD so it
// never shows up in ps.
type conn struct {
uri string
password string
info DatabaseInfo
}
func parseConn(raw string) (conn, error) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "postgres" && u.Scheme != "postgresql") {
return conn{}, errors.New("database url must be a postgres:// URL")
}
c := conn{info: DatabaseInfo{Host: u.Hostname(), Port: u.Port(), Name: strings.TrimPrefix(u.Path, "/")}}
if u.User != nil {
c.info.User = u.User.Username()
c.password, _ = u.User.Password()
u.User = url.User(c.info.User)
}
if c.info.Name == "" {
return conn{}, errors.New("database url names no database")
}
c.uri = u.String()
return c, nil
}
func (c conn) env() []string {
env := os.Environ()
if c.password != "" {
env = append(env, "PGPASSWORD="+c.password)
}
// Never prompt: a timer-driven run with a wrong password must fail, not hang.
return append(env, "PGCONNECT_TIMEOUT=15")
}
// toolCmd is one run of a client tool, named for errors by the tool itself:
// under Tools.Exec the process is kubectl, which says nothing.
type toolCmd struct {
*exec.Cmd
name string
}
// command runs tool with args, directly or under t.Exec.
func (t Tools) command(ctx context.Context, c conn, tool string, args ...string) toolCmd {
if len(t.Exec) > 0 {
argv := append(append(append([]string{}, t.Exec[1:]...), tool), args...)
return toolCmd{exec.CommandContext(ctx, t.Exec[0], argv...), filepath.Base(tool)}
}
cmd := exec.CommandContext(ctx, tool, args...)
cmd.Env = c.env()
return toolCmd{cmd, filepath.Base(tool)}
}
// dsn is what the tools pass as --dbname / -d.
func (t Tools) dsn(c conn) string {
if len(t.Exec) > 0 {
return t.Conn
}
return c.uri
}
// run executes cmd and folds its stderr into the error.
func run(cmd toolCmd) ([]byte, error) {
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
return out, fmt.Errorf("%s: %w", cmd.name, err)
}
return out, fmt.Errorf("%s: %w: %s", cmd.name, err, msg)
}
return out, nil
}
// foreignObjectRe finds the object pg_dump could not read, and its kind.
var foreignObjectRe = regexp.MustCompile(`permission denied for (table|sequence|schema|view|materialized view) ([^\s]+)`)
// dumpHint explains the one pg_dump failure an operator causes without noticing:
// an object created in the felis database by another role (typically postgres,
// from a manual psql session). The dump runs as the felis role and must read
// everything; leaving the object out would make the bundle an incomplete restore.
func dumpHint(err error, db DatabaseInfo, t Tools) string {
m := foreignObjectRe.FindStringSubmatch(err.Error())
if m == nil {
return ""
}
// The superuser's psql: the host's postgres account, or the image's
// postgres role over the container's socket.
su := "sudo -u postgres psql"
if len(t.Exec) > 0 {
su = "sudo " + strings.Join(t.Exec, " ") + " psql -U postgres"
}
kind, name := strings.ToUpper(m[1]), m[2]
return fmt.Sprintf("\n %s %s belongs to another role, so %s cannot dump it. Hand it over with\n"+
" %s -d %s -c 'ALTER %s %s OWNER TO %s'\n"+
" or drop it if it is a leftover.", strings.ToLower(kind), name, db.User, su, db.Name, kind, name, db.User)
}
// BundleName is the file name of a bundle taken at t with label.
func BundleName(t time.Time, label string) string {
return bundlePrefix + t.UTC().Format(stampLayout) + "-" + label + bundleExt
}
// ParseBundleName reverses BundleName: the stamp and label of a bundle file name.
func ParseBundleName(name string) (time.Time, string, bool) {
if !strings.HasPrefix(name, bundlePrefix) || !strings.HasSuffix(name, bundleExt) {
return time.Time{}, "", false
}
rest := strings.TrimSuffix(strings.TrimPrefix(name, bundlePrefix), bundleExt)
if len(rest) < len(stampLayout)+2 || rest[len(stampLayout)] != '-' {
return time.Time{}, "", false
}
t, err := time.Parse(stampLayout, rest[:len(stampLayout)])
label := rest[len(stampLayout)+1:]
if err != nil || !labelRe.MatchString(label) {
return time.Time{}, "", false
}
return t, label, true
}
// List returns the bundles in dir, newest first. A missing dir is no bundles.
func List(dir string) ([]Bundle, error) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var out []Bundle
for _, e := range entries {
if !e.Type().IsRegular() {
continue
}
t, label, ok := ParseBundleName(e.Name())
if !ok {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, Bundle{Name: e.Name(), Path: filepath.Join(dir, e.Name()), Label: label, Created: t, Size: info.Size()})
}
sort.Slice(out, func(i, j int) bool {
if !out[i].Created.Equal(out[j].Created) {
return out[i].Created.After(out[j].Created)
}
return out[i].Name > out[j].Name
})
return out, nil
}
// Newest is the first bundle of label in bundles, which List orders newest
// first.
func Newest(bundles []Bundle, label string) (Bundle, bool) {
for _, b := range bundles {
if b.Label == label {
return b, true
}
}
return Bundle{}, false
}
// Prune deletes all but the newest keep bundles of label (and their sidecars)
// and returns what it removed. keep <= 0 removes nothing.
func Prune(dir, label string, keep int) ([]string, error) {
if keep <= 0 {
return nil, nil
}
all, err := List(dir)
if err != nil {
return nil, err
}
var removed []string
n := 0
for _, b := range all {
if b.Label != label {
continue
}
if n++; n <= keep {
continue
}
if err := os.Remove(b.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
return removed, err
}
_ = os.Remove(b.Path + sumExt)
removed = append(removed, b.Name)
}
return removed, nil
}
// lockDir serializes bundle writers (the nightly timer, a pre-migrate snapshot
// and an operator's manual run) on dir/.lock.
func lockDir(dir string) (func(), error) {
f, err := os.OpenFile(filepath.Join(dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
f.Close()
return nil, fmt.Errorf("lock %s: %w", dir, err)
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
// removeStalePartials drops leftovers of a writer that died mid-bundle. Only
// called under the directory lock, so no live writer owns them.
func removeStalePartials(dir string) {
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), "."+bundlePrefix) && strings.HasSuffix(e.Name(), ".partial") {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
// Backup writes one bundle and returns its path. With ErrServersMissing the
// bundle is written and holds the database, but not the MinecraftServer
// objects.
func Backup(ctx context.Context, o BackupOptions) (string, error) {
if !labelRe.MatchString(o.Label) {
return "", fmt.Errorf("invalid label %q (want [a-z0-9-], e.g. daily or manual)", o.Label)
}
c, err := parseConn(o.DatabaseURL)
if err != nil {
return "", err
}
now := time.Now
if o.Now != nil {
now = o.Now
}
logw := o.Log
if logw == nil {
logw = io.Discard
}
if err := os.MkdirAll(o.Dir, 0o700); err != nil {
return "", err
}
if err := os.Chmod(o.Dir, 0o700); err != nil {
return "", err
}
unlock, err := lockDir(o.Dir)
if err != nil {
return "", err
}
defer unlock()
removeStalePartials(o.Dir)
// Names have one-second resolution. A second bundle of the same label within
// that second (a restore retried right after a failed one takes two
// pre-restore snapshots) moves to the next free second; the lock makes the
// probe race-free.
created := now().UTC().Truncate(time.Second)
name := BundleName(created, o.Label)
for i := 0; ; i++ {
if _, err := os.Lstat(filepath.Join(o.Dir, name)); errors.Is(err, fs.ErrNotExist) {
break
} else if err != nil {
return "", err
}
if i == 60 {
return "", fmt.Errorf("no free bundle name after %s", name)
}
created = created.Add(time.Second)
name = BundleName(created, o.Label)
}
final := filepath.Join(o.Dir, name)
dump := filepath.Join(o.Dir, "."+name+".dump.partial")
defer os.Remove(dump)
if err := dumpTo(ctx, c, o.Tools, dump); err != nil {
return "", err
}
// A dump pg_restore cannot read is not a backup; find out now, not on the
// day it is needed.
if err := listArchive(ctx, c, o.Tools, dump); err != nil {
return "", fmt.Errorf("the dump does not read back: %w", err)
}
m := Manifest{Format: formatV1, CreatedAt: created, Label: o.Label, FelisVersion: o.Version, Database: c.info}
if out, err := run(o.Tools.command(ctx, c, o.Tools.pgDump(), "--version")); err == nil {
m.PGDumpVersion = strings.TrimSpace(string(out))
}
m.SchemaVersion = schemaVersion(ctx, c, o.Tools)
m.Counts = counts(ctx, c, o.Tools)
var members []member
dm, err := fileMember(dumpEntry, dump)
if err != nil {
return "", err
}
members = append(members, dm)
if o.StateDir != "" {
sm, err := stateMembers(o.StateDir)
if err != nil {
return "", fmt.Errorf("read host state: %w", err)
}
members = append(members, sm...)
}
if o.ExportServers != nil {
if data, err := o.ExportServers(ctx); err != nil {
if o.RequireServers {
return "", fmt.Errorf("export the MinecraftServer objects: %w", err)
}
m.ServersError = err.Error()
fmt.Fprintf(logw, "felis db backup: MinecraftServer objects not included: %v\n", err)
} else {
members = append(members, bytesMember(serversEntry, data))
}
}
for _, mb := range members {
m.Files = append(m.Files, mb.entry)
}
sum, err := writeBundle(o.Dir, final, m, members)
if err != nil {
return "", err
}
if err := writeFileAtomic(final+sumExt, []byte(sum+" "+name+"\n")); err != nil {
return "", fmt.Errorf("write checksum: %w", err)
}
if info, err := os.Stat(final); err == nil {
st := Status{At: created, Name: name, Label: o.Label, SizeBytes: info.Size(),
FelisVersion: o.Version, SchemaVersion: m.SchemaVersion, Dir: o.Dir, ServersError: m.ServersError}
if o.Record {
if err := record(ctx, c, o.Tools, st); err != nil {
fmt.Fprintf(logw, "felis db backup: record the backup for the panel: %v\n", err)
}
}
if o.MetricsFile != "" {
if err := writeMetrics(o.MetricsFile, st); err != nil {
fmt.Fprintf(logw, "felis db backup: write %s: %v\n", o.MetricsFile, err)
}
}
}
if removed, err := Prune(o.Dir, o.Label, o.Keep); err != nil {
fmt.Fprintf(logw, "felis db backup: prune old %s bundles: %v\n", o.Label, err)
} else if len(removed) > 0 {
fmt.Fprintf(logw, "felis db backup: pruned %d old %s bundle(s)\n", len(removed), o.Label)
}
if m.ServersError != "" {
return final, fmt.Errorf("%w: %s", ErrServersMissing, m.ServersError)
}
return final, nil
}
// dumpTo writes pg_dump's custom-format archive of the database to path,
// created 0600. The archive travels on stdout, the one channel that reaches
// the host from a tool running under Tools.Exec.
func dumpTo(ctx context.Context, c conn, t Tools, path string) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
cmd := t.command(ctx, c, t.pgDump(), "--format=custom", "--no-password", "--dbname="+t.dsn(c))
var stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = f, &stderr
err = cmd.Run()
if cerr := f.Close(); err == nil && cerr != nil {
return cerr
}
if err != nil {
err = fmt.Errorf("%s: %w", cmd.name, err)
if msg := strings.TrimSpace(stderr.String()); msg != "" {
err = fmt.Errorf("%w: %s", err, msg)
}
return fmt.Errorf("dump the database: %w%s", err, dumpHint(err, c.info, t))
}
return nil
}
// listArchive proves pg_restore can read the archive at path, fed on stdin.
func listArchive(ctx context.Context, c conn, t Tools, path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
cmd := t.command(ctx, c, t.pgRestore(), "--list")
cmd.Stdin = f
_, err = run(cmd)
return err
}
// record upserts st into platform_settings, with DailyAt read off st.Dir. The
// JSON travels as a psql variable, quoted by psql itself, over stdin (-c does
// not interpolate).
func record(ctx context.Context, c conn, t Tools, st Status) error {
if all, err := List(st.Dir); err == nil {
if d, ok := Newest(all, LabelDaily); ok {
st.DailyAt = d.Created
}
}
v, err := json.Marshal(st)
if err != nil {
return err
}
cmd := t.command(ctx, c, t.psql(), "-X", "-q", "-w", "-v", "ON_ERROR_STOP=1", "-v", "v="+string(v), "-d", t.dsn(c))
cmd.Stdin = strings.NewReader("INSERT INTO platform_settings (key, value) VALUES ('" + StatusKey + "', :'v'::jsonb)\n" +
"ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now();\n")
_, err = run(cmd)
return err
}
// writeMetrics rewrites a node-exporter textfile-collector file for st.
func writeMetrics(path string, st Status) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
body := fmt.Sprintf(`# HELP felis_db_backup_last_success_timestamp_seconds Unix time of the newest successful control-plane database backup.
# TYPE felis_db_backup_last_success_timestamp_seconds gauge
felis_db_backup_last_success_timestamp_seconds{label=%q} %d
# HELP felis_db_backup_last_size_bytes Size of the newest control-plane database backup bundle.
# TYPE felis_db_backup_last_size_bytes gauge
felis_db_backup_last_size_bytes{label=%q} %d
`, st.Label, st.At.Unix(), st.Label, st.SizeBytes)
if err := writeFileAtomic(path, []byte(body)); err != nil {
return err
}
// Read by node-exporter, which usually runs unprivileged.
return os.Chmod(path, 0o644)
}
// schemaVersion reads the newest applied migration, or 0 when it cannot.
func schemaVersion(ctx context.Context, c conn, t Tools) int {
out, err := run(t.command(ctx, c, t.psql(), "-X", "-q", "-t", "-A", "-w", "-d", t.dsn(c),
"-c", "SELECT coalesce(max(version), 0) FROM schema_migrations"))
if err != nil {
return 0
}
v, _ := strconv.Atoi(strings.TrimSpace(string(out)))
return v
}
// counts reads the database's Counts, or nil when it does not answer.
func counts(ctx context.Context, c conn, t Tools) *Counts {
out, err := run(t.command(ctx, c, t.psql(), "-X", "-q", "-t", "-A", "-w", "-d", t.dsn(c),
"-c", "SELECT (SELECT count(*) FROM users WHERE deleted_at IS NULL), (SELECT count(*) FROM servers WHERE deleted_at IS NULL)"))
if err != nil {
return nil
}
users, servers, _ := strings.Cut(strings.TrimSpace(string(out)), "|")
u, uerr := strconv.Atoi(users)
s, serr := strconv.Atoi(servers)
if uerr != nil || serr != nil {
return nil
}
return &Counts{Users: u, Servers: s}
}
// member is one bundle entry: a file on disk, bytes, or a symlink.
type member struct {
entry ManifestEntry
path string
data []byte
}
func fileMember(name, path string) (member, error) {
f, err := os.Open(path)
if err != nil {
return member{}, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return member{}, err
}
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return member{}, err
}
return member{entry: ManifestEntry{Name: name, Size: n, SHA256: hex.EncodeToString(h.Sum(nil)), Mode: uint32(info.Mode().Perm())}, path: path}, nil
}
func bytesMember(name string, data []byte) member {
s := sha256.Sum256(data)
return member{entry: ManifestEntry{Name: name, Size: int64(len(data)), SHA256: hex.EncodeToString(s[:]), Mode: 0o600}, data: data}
}
// stateMembers bundles the regular files and symlinks directly in dir, under
// state/<absolute dir>/. Subdirectories are not state bootstrap writes.
func stateMembers(dir string) ([]member, error) {
abs, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(abs)
if err != nil {
return nil, err
}
prefix := stateEntry + filepath.ToSlash(abs) + "/"
var out []member
for _, e := range entries {
if stateSkip[e.Name()] {
continue
}
p := filepath.Join(abs, e.Name())
switch {
case e.Type()&os.ModeSymlink != 0:
target, err := os.Readlink(p)
if err != nil {
return nil, err
}
out = append(out, member{entry: ManifestEntry{Name: prefix + e.Name(), Mode: 0o777, Link: target}})
case e.Type().IsRegular():
m, err := fileMember(prefix+e.Name(), p)
if err != nil {
return nil, err
}
out = append(out, m)
}
}
return out, nil
}
// writeBundle writes MANIFEST.json and the members to final via a .partial and
// returns the bundle's sha256.
func writeBundle(dir, final string, m Manifest, members []member) (string, error) {
manifest, err := json.MarshalIndent(m, "", " ")
if err != nil {
return "", err
}
partial := filepath.Join(dir, "."+filepath.Base(final)+".partial")
f, err := os.OpenFile(partial, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return "", err
}
defer os.Remove(partial)
h := sha256.New()
if err := writeTar(io.MultiWriter(f, h), m.CreatedAt, manifest, members); err != nil {
f.Close()
return "", fmt.Errorf("write bundle: %w", err)
}
if err := f.Sync(); err != nil {
f.Close()
return "", err
}
if err := f.Close(); err != nil {
return "", err
}
if err := os.Rename(partial, final); err != nil {
return "", err
}
if err := syncDir(dir); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func writeFileAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
partial := filepath.Join(dir, "."+filepath.Base(path)+".partial")
defer os.Remove(partial)
f, err := os.OpenFile(partial, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Rename(partial, path); err != nil {
return err
}
return syncDir(dir)
}
func syncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
// Age renders a bundle's age for a human: seconds under a minute, then
// minutes, then days and hours past two days.
func Age(d time.Duration) string {
switch {
case d < 0:
return "0s"
case d < time.Minute:
return d.Truncate(time.Second).String()
case d < 48*time.Hour:
return strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s")
default:
return fmt.Sprintf("%dd%dh", d/(24*time.Hour), d%(24*time.Hour)/time.Hour)
}
}
// Check reports the newest daily bundle in dir and an error when there is none
// or it is older than maxAge. The daily timer is what keeps the backups
// current, so a manual, pre-migrate or off-site bundle taken since is left out:
// it would hide a timer that has stopped.
func Check(dir string, maxAge time.Duration, now time.Time) (*Bundle, error) {
all, err := List(dir)
if err != nil {
return nil, err
}
daily, ok := Newest(all, LabelDaily)
if !ok {
return nil, fmt.Errorf("no daily database backup in %s (felis-db-backup.timer writes them)", dir)
}
if age := now.Sub(daily.Created); age > maxAge {
return &daily, fmt.Errorf("newest daily database backup %s is %s old (limit %s)", daily.Name, Age(age), maxAge)
}
return &daily, nil
}