Go is the default for new module code; the holder is one binary like the licence manager, serving the seat's verbs over the SDK and running the nights beside them. Same behaviour and tests.
473 lines
15 KiB
Go
473 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
|
|
}
|
|
|
|
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 _, err := os.Stat(p); err != nil {
|
|
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 _, err := os.Stat(target); err == nil {
|
|
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
|
|
}
|