Files
mesh-catalog/modules/restic/cmd/restic-backups/hand.go
T
jschoubben 7211227c76
mesh/merge-gate pass: builds restic → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
restic: let a restore point taken by hand go only after a night recorded good, say a missing hand= as a file to push, and say what a failed thinning already let go (novox/hq issue 453)
2026-10-11 17:57:43 +02:00

135 lines
5.5 KiB
Go

package main
// A backup taken by hand (novox/hq issue 453): `node-backup.now`, asked by a person before a risky change or by
// the mesh before a module moves (ADR 0233, the bus's snapshot before its upgrade). The thinning after every
// backup keeps only the newest restore point of each day, so one taken at 02:35 was forgotten by the night at
// 03:01 — while the night's may already hold the damage it was taken against.
//
// So a backup taken by hand is tagged HandTag beside its module's tag, and is kept by restic's own --keep-tag:
// with the thinning grouping by tags, it is its own group too, never counted against the nights. It is kept
// until a later good night of its module exists, and for the machine's keep-hand-taken-days in any case; past
// both, the thinning forgets it by its id (handLetGo). The nights are thinned by their counts alone: no rule
// that keeps one keeps every night.
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
// HandTag marks a restore point taken by hand.
const HandTag = "hand"
// BackUpNow is a backup asked for now, of every module or one: as a night, each restore point tagged HandTag.
func (b *Backups) BackUpNow(ctx context.Context, only string) (map[string]Night, error) {
return b.backUp(ctx, only, true)
}
// isHand is whether a restore point was taken by hand.
func isHand(s Snapshot) bool { return slices.Contains(s.Tags, HandTag) }
// moduleOf is the module a restore point is of; empty when it carries no module's tag.
func moduleOf(s Snapshot) string {
for _, t := range s.Tags {
if m, ok := strings.CutPrefix(t, "module="); ok && m != "" {
return m
}
}
return ""
}
// goodNightsFile is each module's newest night this program recorded as good, by its restore point's short id.
// A snapshot is no proof of a good night: restic writes one, and exits 3, when a source file could not be read,
// and that night is recorded as NOT backed up (one). Nor is Good, which counts a backup taken by hand too.
const goodNightsFile = "good-nights.json"
// GoodNights is each module's newest good night's restore point; empty before the first since novox/hq issue
// 453, and until then every restore point taken by hand is kept.
func (b *Backups) GoodNights() map[string]string {
out := map[string]string{}
if raw, err := os.ReadFile(filepath.Join(b.Where.State, goodNightsFile)); err == nil {
_ = json.Unmarshal(raw, &out)
}
return out
}
func (b *Backups) recordGoodNight(module, snapshot string) {
good := b.GoodNights()
good[module] = snapshot
raw, _ := json.MarshalIndent(good, "", " ")
if err := os.WriteFile(filepath.Join(b.Where.State, goodNightsFile), append(raw, '\n'), 0o600); err != nil {
b.Say("recording %s's good night failed: %v", module, err)
}
}
// latestNights is the time of each module's newest good night, read from its restore point: a module whose
// good night's restore point is not listed has none.
func latestNights(snaps []Snapshot, goodNights map[string]string) map[string]time.Time {
latest := map[string]time.Time{}
for _, s := range snaps {
m := moduleOf(s)
if id := goodNights[m]; m != "" && id != "" && (s.ShortID == id || strings.HasPrefix(s.ID, id)) && !isHand(s) {
latest[m] = s.Time
}
}
return latest
}
// handLetGo is the restore points taken by hand that the thinning forgets: older than days, and with a good night
// of the same module after them. Every other one taken by hand is kept — one of a module that left this machine
// for good, which no later night follows, too: deleting a module's last restore point is a person's act.
func handLetGo(snaps []Snapshot, goodNights map[string]string, now time.Time, days int) []Snapshot {
latest := latestNights(snaps, goodNights)
cutoff := now.AddDate(0, 0, -days)
var out []Snapshot
for _, s := range snaps {
if isHand(s) && s.Time.Before(cutoff) && latest[moduleOf(s)].After(s.Time) {
out = append(out, s)
}
}
return out
}
// HandKept is one restore point taken by hand, as `backed-up` says it.
type HandKept struct {
Snapshot string `json:"snapshot"`
At time.Time `json:"at"`
// KeptAtLeastUntil is when its days are up; WaitsForANight that no later good night of its module exists
// yet, and it is kept until one does as well. The first thinning after both forgets it.
KeptAtLeastUntil time.Time `json:"keptAtLeastUntil"`
WaitsForANight bool `json:"waitsForANight"`
Kept string `json:"kept"`
}
// handKept is a module's restore points taken by hand, oldest first, said with the retention in force.
func handKept(module string, snaps []Snapshot, goodNights map[string]string, keep InForce) []HandKept {
latest := latestNights(snaps, goodNights)
out := []HandKept{}
for _, s := range snaps {
if !isHand(s) || moduleOf(s) != module {
continue
}
k := HandKept{Snapshot: s.ShortID, At: s.Time, WaitsForANight: !latest[module].After(s.Time)}
switch {
case keep.Retention == nil:
k.Kept = "kept: nothing is thinned while the retention is refused"
case k.WaitsForANight:
k.KeptAtLeastUntil = s.Time.AddDate(0, 0, keep.Hand)
k.Kept = fmt.Sprintf("kept as taken by hand until a later good night of %s exists, and at least until %s",
module, k.KeptAtLeastUntil.UTC().Format(time.RFC3339))
default:
k.KeptAtLeastUntil = s.Time.AddDate(0, 0, keep.Hand)
k.Kept = fmt.Sprintf("kept as taken by hand until %s; the first thinning after forgets it",
k.KeptAtLeastUntil.UTC().Format(time.RFC3339))
}
out = append(out, k)
}
slices.SortFunc(out, func(a, b HandKept) int { return a.At.Compare(b.At) })
return out
}