Files
mesh-catalog/modules/restic/cmd/restic-backups/backups.go
T
jschoubben 08c7a79f79 restic: ask as root whether a directory is there
The first run on the control node called postgres's dumps missing: the holder looked as the
runtime's account, which cannot see inside a store's 0700 data directory. Every other act already
runs as root; the existence checks do too now.
2026-10-05 12:31:07 +02:00

481 lines
15 KiB
Go

// The machine's backups (novox/hq ADR 0214, to-be 43).
//
// The mesh composes what to back up: every module on the machine contributes `backup` lines to the
// node-backup seat, and the mesh writes them, each module's under a `# <module>` line and with its
// directories already filled, into one file this module reads. Two kinds of line:
//
// run <shell command> run as root before the module's snapshot — a consistent dump of a store
// path <directory> a directory the module's snapshot keeps
//
// Each module gets one snapshot a night, tagged with its name, so a module is listed, kept and
// restored on its own. Everything lands in one repository on the machine — deduplicated, so every
// night is a complete restore point and only what changed costs space — and is thinned to 14 daily,
// 8 weekly and 6 monthly. Against mistakes, not disasters: nothing leaves the machine.
//
// Root's: the dumps read every store and the repository holds every module's data, and the runtime
// launching this binary runs as the operator's account, so restic and the run lines go through sudo
// without a prompt where the account is not root (ADR 0175 §4).
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"time"
)
// Runner runs one command and answers what it printed, so the backups can be tested without restic
// or a store.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// escalated is the command as it is run: as given when this process is root, else through sudo
// without a prompt.
func escalated(uid int, name string, args []string) (string, []string) {
if uid == 0 {
return name, args
}
return "sudo", append([]string{"-n", name}, args...)
}
// execRunner runs it for real. A night's dump of a large store takes a while; six hours is a dump
// that will not finish.
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 6*time.Hour)
defer cancel()
program, argv := escalated(os.Getuid(), name, args)
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, program, argv...)
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
if program == "sudo" && strings.HasPrefix(said, "sudo:") {
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
}
lines := strings.Split(said, "\n")
if len(lines) > 3 {
lines = lines[len(lines)-3:]
}
if said == "" {
return "", fmt.Errorf("%s: %w", name, err)
}
return "", errors.New(strings.Join(lines, " / "))
}
return stdout.String(), nil
}
// Declared is what one module declared.
type Declared struct {
Module string `json:"module"`
Runs []string `json:"runs"`
Paths []string `json:"paths"`
}
var moduleHeader = regexp.MustCompile(`^#\s*([a-z0-9][a-z0-9-]*)$`)
// parseDeclared reads the composed file into each module's declaration, in the order the mesh wrote
// them. A line before any module, a comment that is not a module's name, or a blank, is nothing; a
// line this holder does not read is refused, naming the module, rather than skipped.
func parseDeclared(text string) ([]Declared, error) {
var out []Declared
current := -1
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if m := moduleHeader.FindStringSubmatch(line); m != nil {
out = append(out, Declared{Module: m[1]})
current = len(out) - 1
continue
}
if strings.HasPrefix(line, "#") || current < 0 {
continue
}
kind, value, _ := strings.Cut(line, " ")
value = strings.TrimSpace(value)
d := &out[current]
switch {
case kind == "run" && value != "":
d.Runs = append(d.Runs, value)
case kind == "path" && strings.HasPrefix(value, "/") && !strings.ContainsAny(value, " \t"):
d.Paths = append(d.Paths, value)
default:
return nil, fmt.Errorf("%s contributes a backup line this holder does not read: %s", d.Module, line)
}
}
kept := out[:0]
for _, d := range out {
if len(d.Runs) > 0 || len(d.Paths) > 0 {
kept = append(kept, d)
}
}
return kept, nil
}
// Snapshot is one restore point, as restic lists it.
type Snapshot struct {
ID string `json:"id"`
ShortID string `json:"short_id"`
Time time.Time `json:"time"`
Paths []string `json:"paths"`
Tags []string `json:"tags"`
Hostname string `json:"hostname"`
}
// Night is how one module's last night went.
type Night struct {
OK bool `json:"ok"`
At time.Time `json:"at"`
Snapshot string `json:"snapshot,omitempty"`
Error string `json:"error,omitempty"`
}
// Keep is the rotation (novox/hq ADR 0214).
var Keep = struct{ Daily, Weekly, Monthly int }{14, 8, 6}
func tagOf(module string) string { return "module=" + module }
// Where is where the mesh put this module's things.
type Where struct {
Declared, Repository, PasswordFile, State string
}
func whereFromEnv() (Where, error) {
var missing []string
get := func(k string) string {
v := os.Getenv(k)
if v == "" {
missing = append(missing, k)
}
return v
}
w := Where{
Declared: get("MESH_BACKUP_DECLARED"),
Repository: get("MESH_BACKUP_REPOSITORY"),
PasswordFile: get("MESH_BACKUP_PASSWORD_FILE"),
State: get("MESH_BACKUP_STATE"),
}
if len(missing) > 0 {
return w, fmt.Errorf("%s not set; the mesh gives them to this module", strings.Join(missing, ", "))
}
return w, nil
}
// Backups is the machine's backups. One thing at a time: two nights, or a night and a restore, never
// share a dump.
type Backups struct {
Where Where
Run Runner
Now func() time.Time
Say func(format string, args ...any)
mu sync.Mutex
}
// exists is whether a path is there, asked as root: a store's directory is often its own user's
// alone (postgres's 0700 data directory), and the runtime's account asking for itself would see
// nothing — every such directory "missing", and its module never backed up.
func (b *Backups) exists(ctx context.Context, path string) bool {
_, err := b.Run(ctx, "test", "-e", path)
return err == nil
}
func (b *Backups) restic(ctx context.Context, args ...string) (string, error) {
return b.Run(ctx, "restic", append([]string{"--repo", b.Where.Repository, "--password-file", b.Where.PasswordFile, "--no-cache"}, args...)...)
}
// Declared is what the modules on this machine declared.
func (b *Backups) Declared() ([]Declared, error) {
raw, err := os.ReadFile(b.Where.Declared)
if err != nil {
return nil, err
}
return parseDeclared(string(raw))
}
func (b *Backups) nightsFile() string { return filepath.Join(b.Where.State, "nights.json") }
// Nights is how each module's last night went.
func (b *Backups) Nights() map[string]Night {
nights := map[string]Night{}
if raw, err := os.ReadFile(b.nightsFile()); err == nil {
_ = json.Unmarshal(raw, &nights)
}
return nights
}
func (b *Backups) record(module string, n Night) {
nights := b.Nights()
nights[module] = n
raw, _ := json.MarshalIndent(nights, "", " ")
if err := os.WriteFile(b.nightsFile(), append(raw, '\n'), 0o600); err != nil {
b.Say("recording %s's night failed: %v", module, err)
}
}
var noRepository = regexp.MustCompile(`(?i)does not exist|unable to open config file|Is there a repository at the following location`)
// ensureRepository makes the repository the first time. One that exists and cannot be opened is
// said, never replaced: replacing it would discard every restore point to fix a password.
func (b *Backups) ensureRepository(ctx context.Context) error {
_, err := b.restic(ctx, "cat", "config")
if err == nil {
return nil
}
if !noRepository.MatchString(err.Error()) {
return fmt.Errorf("the repository at %s cannot be opened, and is left as it is: %v", b.Where.Repository, err)
}
b.Say("no repository at %s; making one", b.Where.Repository)
_, err = b.restic(ctx, "init")
return err
}
// one is one module's night: its run lines, then one snapshot of its paths. A failure is that
// module's alone.
func (b *Backups) one(ctx context.Context, d Declared) Night {
n := Night{At: b.Now().UTC()}
fail := func(err error) Night {
n.Error = err.Error()
b.Say("%s: NOT backed up: %s", d.Module, n.Error)
return n
}
for _, command := range d.Runs {
if _, err := b.Run(ctx, "sh", "-c", command); err != nil {
return fail(err)
}
}
if len(d.Paths) == 0 {
return fail(errors.New("it runs a dump and names no directory to keep it from"))
}
var missing []string
for _, p := range d.Paths {
if !b.exists(ctx, p) {
missing = append(missing, p)
}
}
if len(missing) > 0 {
return fail(fmt.Errorf("%s does not exist", strings.Join(missing, ", ")))
}
out, err := b.restic(ctx, append([]string{"backup", "--json", "--tag", tagOf(d.Module)}, d.Paths...)...)
if err != nil {
return fail(err)
}
scanner := bufio.NewScanner(strings.NewReader(out))
scanner.Buffer(make([]byte, 1024*1024), 16*1024*1024)
for scanner.Scan() {
var m struct {
MessageType string `json:"message_type"`
SnapshotID string `json:"snapshot_id"`
}
if json.Unmarshal(scanner.Bytes(), &m) == nil && m.MessageType == "summary" && len(m.SnapshotID) >= 8 {
n.Snapshot = m.SnapshotID[:8]
}
}
n.OK = true
b.Say("%s: backed up (%s)", d.Module, n.Snapshot)
return n
}
// BackUp is a night: every module, or one, then the rotation. It answers each module's outcome.
func (b *Backups) BackUp(ctx context.Context, only string) (map[string]Night, error) {
b.mu.Lock()
defer b.mu.Unlock()
if err := b.ensureRepository(ctx); err != nil {
return nil, err
}
all, err := b.Declared()
if err != nil {
return nil, err
}
chosen := all
if only != "" {
chosen = nil
var names []string
for _, d := range all {
names = append(names, d.Module)
if d.Module == only {
chosen = append(chosen, d)
}
}
if len(chosen) == 0 {
return nil, fmt.Errorf("%s declares nothing to back up on this machine; it backs up %s", only, orNothing(names))
}
}
outcome := map[string]Night{}
for _, d := range chosen {
outcome[d.Module] = b.one(ctx, d)
b.record(d.Module, outcome[d.Module])
}
if _, err := b.restic(ctx, "forget", "--prune", "--group-by", "host,tags",
"--keep-daily", fmt.Sprint(Keep.Daily), "--keep-weekly", fmt.Sprint(Keep.Weekly), "--keep-monthly", fmt.Sprint(Keep.Monthly)); err != nil {
b.Say("thinning the restore points failed, and every one is kept: %v", err)
}
return outcome, nil
}
func orNothing(names []string) string {
if len(names) == 0 {
return "nothing"
}
return strings.Join(names, ", ")
}
// Snapshots is the restore points, of one module or all.
func (b *Backups) Snapshots(ctx context.Context, module string) ([]Snapshot, error) {
args := []string{"snapshots", "--json"}
if module != "" {
args = append(args, "--tag", tagOf(module))
}
out, err := b.restic(ctx, args...)
if err != nil {
return nil, err
}
var snaps []Snapshot
if strings.TrimSpace(out) == "" {
return nil, nil
}
if err := json.Unmarshal([]byte(out), &snaps); err != nil {
return nil, fmt.Errorf("restic listed its snapshots in a form this holder does not read: %v", err)
}
return snaps, nil
}
// ModuleBackups is what `backed-up` says about one module.
type ModuleBackups struct {
Module string `json:"module"`
Runs int `json:"runs"`
Paths []string `json:"paths"`
LastNight *Night `json:"lastNight"`
RestorePoints int `json:"restorePoints"`
Newest *Snapshot `json:"newest,omitempty"`
}
// BackedUp is what is backed up here: each module, what it declared, its last night and its restore
// points.
func (b *Backups) BackedUp(ctx context.Context, module string) ([]ModuleBackups, error) {
declared, err := b.Declared()
if err != nil {
return nil, err
}
nights := b.Nights()
snaps, _ := b.Snapshots(ctx, module)
out := []ModuleBackups{}
for _, d := range declared {
if module != "" && d.Module != module {
continue
}
m := ModuleBackups{Module: d.Module, Runs: len(d.Runs), Paths: d.Paths}
if n, ok := nights[d.Module]; ok {
m.LastNight = &n
}
for _, s := range snaps {
if slices.Contains(s.Tags, tagOf(d.Module)) {
m.RestorePoints++
newest := s
m.Newest = &newest
}
}
out = append(out, m)
}
return out, nil
}
// Restored is what a restore put where.
type Restored struct {
Module string `json:"module"`
From Snapshot `json:"from"`
Restored []string `json:"restored"`
Live string `json:"live"`
}
// Restore puts a module's data from a restore point BESIDE the live data: each directory as
// <path>.restored-<stamp>. A target that already exists is refused, never overwritten.
func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*Restored, error) {
b.mu.Lock()
defer b.mu.Unlock()
mine, err := b.Snapshots(ctx, module)
if err != nil {
return nil, err
}
if len(mine) == 0 {
return nil, fmt.Errorf("%s has no restore point on this machine", module)
}
chosen := mine[len(mine)-1]
if snapshot != "" {
found := false
var listed []string
for _, s := range mine {
listed = append(listed, fmt.Sprintf("%s (%s)", s.ShortID, s.Time.Format(time.RFC3339)))
if s.ShortID == snapshot || strings.HasPrefix(s.ID, snapshot) {
chosen, found = s, true
}
}
if !found {
return nil, fmt.Errorf("%s has no restore point %s; it has %s", module, snapshot, strings.Join(listed, ", "))
}
}
paths := chosen.Paths
if path != "" {
if !slices.Contains(chosen.Paths, path) {
return nil, fmt.Errorf("restore point %s of %s holds %s, not %s", chosen.ShortID, module, strings.Join(chosen.Paths, ", "), path)
}
paths = []string{path}
}
stamp := b.Now().UTC().Format("20060102-150405")
r := &Restored{Module: module, From: chosen, Live: "untouched — swapping it in is a person's act"}
for _, p := range paths {
target := p + ".restored-" + stamp
if b.exists(ctx, target) {
return nil, fmt.Errorf("%s already exists; nothing is restored over anything", target)
}
if _, err := b.restic(ctx, "restore", chosen.ID+":"+p, "--target", target); err != nil {
return nil, err
}
r.Restored = append(r.Restored, target)
b.Say("%s: restored %s from %s to %s", module, p, chosen.ShortID, target)
}
return r, nil
}
// Check is the weekly look at the repository's own integrity, with a sample of the data read back.
func (b *Backups) Check(ctx context.Context) error {
b.mu.Lock()
defer b.mu.Unlock()
_, err := b.restic(ctx, "check", "--read-data-subset", "5%")
return err
}
// nextNight is when the next night is due: the given hour, local time, today while it is still
// ahead, else tomorrow.
func nextNight(now time.Time, hour int) time.Time {
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, now.Location())
if !next.After(now) {
next = next.AddDate(0, 0, 1)
}
return next
}
// missedANight is whether a night was missed: the newest good night of any module is older than a
// day and a bit — the machine was off, or this module was not running, at the hour.
func missedANight(nights map[string]Night, now time.Time) bool {
var newest time.Time
for _, n := range nights {
if n.OK && n.At.After(newest) {
newest = n.At
}
}
return newest.IsZero() || now.Sub(newest) > 26*time.Hour
}