Files
mesh-catalog/modules/restic/cmd/restic-backups/health.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

190 lines
8.8 KiB
Go

package main
// The module's health (novox/hq ADR 0240, issue 454), judged from what the nights left: a failure must be
// loud, and until this one a failed backup, a failed thinning and a refused retention were journal lines
// only — a typo in a setting let the repository grow until a disk condition fired.
//
// Unhealthy when a module declared here was NOT backed up at its last night, when the last night did not run
// at all, when no night has backed anything up while this machine was up for more than a day (awake.go), when
// the night's process has stopped counting, when the last thinning failed, or when the retention file is
// missing, cannot be read or is not in the form the mesh writes. A retention setting the night refuses is the operator's to give again: the
// answer is *waits*, naming the setting (ADR 0283), and the controller says it as the module's needs-operator
// condition. The node-engine asks this through the node tools for the night's process; the controller
// raises module.restic.<machine>.unhealthy on the second look that says so, with these words.
import (
"context"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// Health is the answer the node-engine reads from the tool check: mesh-sdk's answer (state, healthy, why,
// waits), with what was found and what was said beside it.
type Health struct {
health.Answer
Found []string `json:"found,omitempty"`
Said []string `json:"said,omitempty"`
}
// Seen is what the health is judged from.
type Seen struct {
Declared []Declared
DeclaredErr error
Nights map[string]Night
Good map[string]time.Time
Run Run
Retention InForce
// Awake is the night's process's count of how long this machine was up since its newest good night; nil
// before the process ever counted.
Awake *Awake
Now time.Time
}
func hours(d time.Duration) string {
if d < 2*time.Hour {
return fmt.Sprintf("%d minutes", int(d.Minutes()))
}
return fmt.Sprintf("%d hours", int(d.Hours()))
}
func clock(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }
// Judge is the health of what was seen. Pure.
func Judge(s Seen) Health {
var found, said []string
var waits []health.Wait
if s.DeclaredErr != nil {
found = append(found, fmt.Sprintf("What the modules here declare to back up does not read (%v), so no night can "+
"back anything up; push this machine (mesh-controller.push) so the mesh writes it again.", s.DeclaredErr))
}
if s.Run.Failed != "" {
found = append(found, fmt.Sprintf("The night of %s did not run: %s. No module was backed up; fix what the error "+
"names, then take one with node-backup.now on this machine.", clock(s.Run.At), s.Run.Failed))
}
if a := s.Awake; a != nil {
newest := "no night has backed anything up here yet"
if !a.From.IsZero() {
newest = "the newest good night was at " + clock(a.From)
}
switch {
case s.Now.Sub(a.Looked) > Stale:
found = append(found, fmt.Sprintf("The night's process last counted at %s, more than %s ago, so whether a "+
"night is overdue is not known; %s. Read the journal of restic-night (node-service-manager.journal) "+
"on this machine.", clock(a.Looked), hours(Stale), newest))
case a.Up > Overdue:
found = append(found, fmt.Sprintf("No night is happening: %s, and this machine has been up with the "+
"night's process running for %s since. A night that hangs, waits on a restore or a deletion holding "+
"the repository, or never starts leaves no failed night behind; read the journal of restic-night "+
"(node-service-manager.journal), then take one with node-backup.now on this machine.", newest, hours(a.Up)))
default:
said = append(said, fmt.Sprintf("Up %s with the night's process running since the newest good night; "+
"said when that passes %s.", hours(a.Up), hours(Overdue)))
}
}
failed := map[string]Night{}
for _, d := range s.Declared {
if n, ok := s.Nights[d.Module]; ok && !n.OK {
failed[d.Module] = n
}
}
names := make([]string, 0, len(failed))
for m := range failed {
names = append(names, m)
}
sort.Strings(names)
for _, m := range names {
n := failed[m]
since := "it has no restore point here"
if g, ok := s.Good[m]; ok && !g.IsZero() {
since = "its newest restore point is of " + clock(g)
}
found = append(found, fmt.Sprintf("%s was NOT backed up at %s: %s; %s. Fix what the error names, then take one "+
`with node-backup.now {"module": "%s"} on this machine.`, m, clock(n.At), strings.TrimSuffix(n.Error, "."), since, m))
}
if t := s.Run.Thinned; t != nil && !t.OK && t.Error != "" {
found = append(found, fmt.Sprintf("Thinning the restore points failed at %s: %s. Nothing was forgotten and every "+
"restore point is kept, so the repository grows until a thinning succeeds; fix what the error names, then "+
"node-backup.now on this machine thins again.", clock(t.At), strings.TrimSuffix(t.Error, ".")))
}
r := s.Retention
switch {
case r.Refused == "" && r.Retention != nil:
said = append(said, fmt.Sprintf("The next thinning keeps %d daily, %d weekly and %d monthly restore points of each module.",
r.Daily, r.Weekly, r.Monthly))
case r.Setting != "":
value := "not set"
if r.Value != "" {
value = fmt.Sprintf("%q", r.Value)
}
line := fmt.Sprintf("The retention setting %s is refused: it is %s, and it is a count from 1 to 9999. Until it is, "+
"nothing is thinned and every restore point is kept, so the repository grows; set %s for restic on this machine "+
"(mesh-controller.settings).", r.Setting, value, r.Setting)
said = append(said, line)
waits = append(waits, health.Wait{Part: "night", Setting: r.Setting,
What: "a count from 1 to 9999 for " + r.Setting})
case strings.Contains(r.Refused, Unreadable):
found = append(found, fmt.Sprintf("The retention is refused: %s; nothing is thinned and every restore point is "+
"kept, so the repository grows. The mesh writes the file readable (0644) and root's; one the account cannot "+
"read was written by an older build of this module, or changed on the machine, and the next push of this "+
"machine (mesh-controller.push) writes it readable again.", r.Refused))
case strings.HasSuffix(r.Refused, Missing):
found = append(found, fmt.Sprintf("The retention is refused: %s; nothing is thinned and every restore point is "+
"kept until it is there again, rather than thinned by a number nobody set; push this machine "+
"(mesh-controller.push) so the mesh writes it again.", r.Refused))
case strings.Contains(r.Refused, RetentionFile+": "):
// Read, and not in the form the mesh writes: something other than the mesh wrote it.
found = append(found, fmt.Sprintf("The retention is refused: %s; nothing is thinned and every restore point is "+
"kept, so the repository grows. The mesh writes only daily=, weekly= and monthly=, so something else wrote "+
"this file; push this machine (mesh-controller.push) so the mesh writes it again.", r.Refused))
default:
found = append(found, fmt.Sprintf("The retention is refused: %s; nothing is thinned and every restore point is "+
"kept, so the repository grows. Fix what the error names.", r.Refused))
}
if len(found) > 0 {
why := strings.Join(found, " ")
if len(waits) > 0 {
why += " " + said[len(said)-1]
}
return Health{Answer: health.Unhealthy(why, waits...), Found: found, Said: said}
}
if len(waits) > 0 {
return Health{Answer: health.Waiting(said[len(said)-1], waits...), Said: said}
}
return Health{Answer: health.Healthy(healthySaid(s, said)), Said: said}
}
// healthySaid is what was seen, and only that, when nothing is wrong.
func healthySaid(s Seen, said []string) string {
var parts []string
switch {
case len(s.Declared) == 0:
parts = append(parts, "No module here declares anything to back up.")
case newestGood(s.Nights).IsZero():
parts = append(parts, "No good night is recorded here yet.")
default:
parts = append(parts, fmt.Sprintf("No module declared here failed its last night; the newest good night was at %s.",
clock(newestGood(s.Nights))))
}
switch t := s.Run.Thinned; {
case t == nil:
parts = append(parts, "No thinning is recorded yet.")
case t.OK && t.Kept != nil:
parts = append(parts, fmt.Sprintf("The last thinning, at %s, kept %d daily, %d weekly and %d monthly restore points.",
clock(t.At), t.Kept.Daily, t.Kept.Weekly, t.Kept.Monthly))
case t.Refused != "":
parts = append(parts, fmt.Sprintf("The last thinning, at %s, thinned nothing: its retention was refused then.", clock(t.At)))
}
return strings.Join(append(parts, said...), " ")
}
// Health reads what the nights left and the retention as it is now, and judges them.
func (b *Backups) Health(ctx context.Context) Health {
declared, err := b.Declared()
return Judge(Seen{Declared: declared, DeclaredErr: err, Nights: b.Nights(), Good: b.Good(), Run: b.LastRun(),
Retention: b.inForce(ctx), Awake: b.LastAwake(), Now: b.Now()})
}