Files
mesh-catalog/modules/restic/cmd/restic-backups/retention.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

151 lines
6.0 KiB
Go

package main
// The retention as a preference (novox/hq issue 416, ADR 0262). The mesh writes the settings
// keep-daily, keep-weekly and keep-monthly, and keep-hand-taken-days (novox/hq issue 453), into RetentionFile in this module's state, each defaulting
// to today's rotation, so a machine that sets none thins exactly as before. The controller does not
// type a setting yet (ADR 0164 is proposed), so this program is where a bad value is caught: it is
// refused here and never handed to restic (issue 346: a raw setting is an injection point).
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// Retention is how many restore points each period keeps, and how many days a restore point taken by hand is
// kept at least (hand.go).
type Retention struct {
Daily int `json:"daily"`
Weekly int `json:"weekly"`
Monthly int `json:"monthly"`
Hand int `json:"handDays"`
}
// TodaysRetention is the rotation before it was a preference (novox/hq ADR 0214), and still the
// manifest's default — never a fallback for a file that is not there (novox/hq issue 454). A restore point
// taken by hand is kept a week at least: long enough to notice what a change broke (novox/hq issue 453).
var TodaysRetention = Retention{14, 8, 6, 7}
// RetentionFile is the file the mesh writes the retention into, in this module's state.
const RetentionFile = "retention.conf"
// A count from 1 to 9999 with no leading zero: 0 would make restic ignore the period, and a number
// past four digits is a typo, not a policy.
var count = regexp.MustCompile(`^[1-9][0-9]{0,3}$`)
// ParseRetention reads daily=, weekly=, monthly= and hand=, each once and each a count; anything else is
// refused naming the setting it came from.
func ParseRetention(text string) (Retention, error) {
settingOf := map[string]string{"daily": "keep-daily", "weekly": "keep-weekly", "monthly": "keep-monthly",
"hand": "keep-hand-taken-days"}
seen := map[string]int{}
for _, l := range strings.Split(text, "\n") {
l = strings.TrimSpace(l)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
k, v, ok := strings.Cut(l, "=")
if !ok {
return Retention{}, fmt.Errorf("%q is not key=value", l)
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
setting, known := settingOf[k]
switch {
case !known:
return Retention{}, fmt.Errorf("%q is no retention this module reads (daily, weekly, monthly, hand)", k)
case seen[k] != 0:
return Retention{}, fmt.Errorf("%s is given twice", k)
case !count.MatchString(v):
return Retention{}, &SettingRefused{Setting: setting, Value: v}
}
seen[k], _ = strconv.Atoi(v)
}
for _, k := range []string{"daily", "weekly", "monthly"} {
if seen[k] == 0 {
return Retention{}, &SettingRefused{Setting: settingOf[k]}
}
}
// keep-hand-taken-days has a default, so the mesh always writes hand=: a file without it was written by
// something other than this module's manifest — an older one, before novox/hq issue 453 — and pushing the
// machine writes it again. No setting for the operator to give.
if seen["hand"] == 0 {
return Retention{}, errors.New(HandMissing)
}
return Retention{seen["daily"], seen["weekly"], seen["monthly"], seen["hand"]}, nil
}
// HandMissing is said of a retention file without the hand= line the mesh writes from keep-hand-taken-days.
const HandMissing = "the hand= line is missing, which the mesh writes from keep-hand-taken-days and its default"
// SettingRefused is one retention setting the night refuses: the operator's to give again.
type SettingRefused struct{ Setting, Value string }
func (e *SettingRefused) Error() string {
if e.Value == "" {
return fmt.Sprintf("the %s setting is missing", e.Setting)
}
return fmt.Sprintf("the %s setting is %q; it is a count from 1 to 9999", e.Setting, e.Value)
}
// Missing is said of a retention file that is not there. Every machine has had the file since mesh-catalog
// #200 rolled out (seen in each machine's plan, 2026-10-11), so a missing one is lost, not old: it is refused
// rather than thinned by 14/8/6, which would thin by a number nobody set (novox/hq issue 454).
const Missing = "is missing"
// Unreadable is said of a retention file this program's account may not read.
const Unreadable = "cannot be read by the account this module runs as"
// retention is this machine's, as the file the mesh wrote says; an error when it is missing, cannot be
// read, or does not read, and a *SettingRefused within it when one setting is what is wrong.
//
// The mesh writes the file root's and readable by everyone (0644): the account the night runs as reads it
// and cannot change it, and the counts are no secret (novox/hq issue 454 — written 0600 before, which the
// account could not read, so every night would have refused it and thinned nothing).
func (b *Backups) retention(context.Context) (Retention, error) {
path := filepath.Join(b.Where.State, RetentionFile)
raw, err := os.ReadFile(path)
switch {
case errors.Is(err, fs.ErrNotExist):
return Retention{}, fmt.Errorf("%s %s", path, Missing)
case errors.Is(err, fs.ErrPermission):
return Retention{}, fmt.Errorf("%s %s (%v)", path, Unreadable, errors.Unwrap(err))
case err != nil:
return Retention{}, err
}
keep, err := ParseRetention(string(raw))
if err != nil {
return Retention{}, fmt.Errorf("%s: %w", path, err)
}
return keep, nil
}
// InForce is the retention a night thins by, or why it thins nothing: what `backed-up` says beside each
// module and what the health judges.
type InForce struct {
*Retention
Refused string `json:"refused,omitempty"`
// Setting is the setting a refusal names, when one setting is what is wrong.
Setting string `json:"setting,omitempty"`
// Value is what that setting is now.
Value string `json:"-"`
}
func (b *Backups) inForce(ctx context.Context) InForce {
keep, err := b.retention(ctx)
if err == nil {
return InForce{Retention: &keep}
}
f := InForce{Refused: err.Error()}
var s *SettingRefused
if errors.As(err, &s) {
f.Setting, f.Value = s.Setting, s.Value
}
return f
}