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 superseded: a newer head of the same pull request
A failed night was a journal line only, so a typo in a retention setting let the repository grow until a disk filled. The night and the measuring now run as processes the node-engine judges: the night by backup_health, which is unhealthy for a module not backed up, a night that did not run, a failed thinning or a missing retention file, and waits naming the setting when one is refused; the measuring by its unit. A file lock keeps a night and the tool runtime's now and restore apart. The retention file is root's alone, so the account reads it through sudo: until now every night would have refused it as unreadable. Every machine has the file, so a missing one is refused instead of thinned by 14/8/6. backed-up says the retention in force and the last thinning per module.
164 lines
5.7 KiB
Go
164 lines
5.7 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"
|
|
"sync"
|
|
"syscall"
|
|
)
|
|
|
|
// 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"
|
|
|
|
// retentionRead is the last read of the file, by what its stat said, so a health look every minute does not
|
|
// run sudo every minute while nothing changed.
|
|
type retentionRead struct {
|
|
mu sync.Mutex
|
|
key string
|
|
keep Retention
|
|
err error
|
|
}
|
|
|
|
// 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 as root's alone (a trusted file, novox/hq issue 339) and this program runs as the
|
|
// operator account, so what the account may not read is read as root, as the declared directories are
|
|
// (novox/hq issue 454: until then every night refused it as unreadable and thinned nothing).
|
|
func (b *Backups) retention(ctx context.Context) (Retention, error) {
|
|
path := filepath.Join(b.Where.State, RetentionFile)
|
|
info, err := os.Stat(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return Retention{}, fmt.Errorf("%s %s", path, Missing)
|
|
}
|
|
if err != nil {
|
|
return Retention{}, err
|
|
}
|
|
key := fmt.Sprint(info.ModTime().UnixNano(), info.Size(), info.Mode())
|
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
key += fmt.Sprint(" ", st.Ino, st.Ctim.Nano())
|
|
}
|
|
b.read.mu.Lock()
|
|
defer b.read.mu.Unlock()
|
|
if b.read.key == key {
|
|
return b.read.keep, b.read.err
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if errors.Is(err, fs.ErrPermission) {
|
|
var out string
|
|
out, err = b.Run(ctx, "cat", "--", path)
|
|
raw = []byte(out)
|
|
}
|
|
if err != nil {
|
|
// Not kept: a read that failed may succeed at the next look.
|
|
return Retention{}, fmt.Errorf("%s cannot be read: %w", path, err)
|
|
}
|
|
keep, err := ParseRetention(string(raw))
|
|
if err != nil {
|
|
err = fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
b.read.key, b.read.keep, b.read.err = key, keep, err
|
|
return keep, err
|
|
}
|
|
|
|
// 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
|
|
}
|