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 }