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
151 lines
6.0 KiB
Go
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
|
|
}
|