Files
mesh-catalog/modules/restic/cmd/restic-backups/health.go
T
jschoubben 7211227c76
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: let a restore point taken by hand go only after a night recorded good, say a missing hand= as a file to push, and say what a failed thinning already let go (novox/hq issue 453)
2026-10-11 17:57:43 +02:00

205 lines
9.5 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 != "" {
// Restore points taken by hand are let go before the counts' thinning, so they are gone when it fails after
// (novox/hq issue 453): said, rather than "nothing was forgotten".
kept := "Nothing was forgotten and every restore point is kept"
if len(t.LetGo) > 0 {
kept = "It let go " + strings.Join(t.LetGo, ", ") + ", taken by hand, past their days and a later night; " +
"every other restore point is kept"
}
found = append(found, fmt.Sprintf("Thinning the restore points failed at %s: %s. %s, 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, "."), kept))
}
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, "+
"and each taken by hand until a later good night and %d days at least.", r.Daily, r.Weekly, r.Monthly, r.Hand))
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=, monthly= and hand=, 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%s.",
clock(t.At), t.Kept.Daily, t.Kept.Weekly, t.Kept.Monthly, letGoSaid(t.LetGo)))
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()})
}
// letGoSaid is the restore points taken by hand a thinning let go, as the health says them (novox/hq issue 453).
func letGoSaid(ids []string) string {
if len(ids) == 0 {
return ""
}
return ", and let go " + strings.Join(ids, ", ") + ", taken by hand, past their days and a later night"
}