Files
mesh-catalog/modules/restic/cmd/restic-backups/retention.go
T
jschoubben cbe9fa7a2c
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: raise a night that never happens, and have the mesh write the retention readable (novox/hq issue 454)
A hung night, a lock held for hours or a lock that cannot be taken left no
failed night behind, so it stayed healthy. The night's process now counts
how long the machine was up since its newest good night, by the monotonic
clock so a sleeping laptop is not blamed, and past 26 hours it is raised.

The retention file is written 0644 and root's instead of read through
sudo: the account reads it and still cannot change it. An unreadable file
names its cause, and the healthy answer says only what was seen.
2026-10-11 12:14:54 +02:00

138 lines
5.1 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 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.
type Retention struct {
Daily int `json:"daily"`
Weekly int `json:"weekly"`
Monthly int `json:"monthly"`
}
// 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).
var TodaysRetention = Retention{14, 8, 6}
// 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= and monthly=, 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"}
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)", 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]}
}
}
return Retention{seen["daily"], seen["weekly"], seen["monthly"]}, nil
}
// 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
}