restic: raise a night that never happens, and have the mesh write the retention readable (novox/hq issue 454)
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

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.
This commit is contained in:
2026-10-11 12:14:54 +02:00
parent d649309895
commit cbe9fa7a2c
8 changed files with 310 additions and 84 deletions
+15 -41
View File
@@ -16,8 +16,6 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"syscall"
)
// Retention is how many restore points each period keeps.
@@ -87,55 +85,31 @@ func (e *SettingRefused) Error() string {
// 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
}
// 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 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) {
// 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)
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)
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 {
err = fmt.Errorf("%s: %w", path, err)
return Retention{}, fmt.Errorf("%s: %w", path, err)
}
b.read.key, b.read.keep, b.read.err = key, keep, err
return keep, err
return keep, nil
}
// InForce is the retention a night thins by, or why it thins nothing: what `backed-up` says beside each