Go is the default for new module code; the holder is one binary like the licence manager, serving the seat's verbs over the SDK and running the nights beside them. Same behaviour and tests.
138 lines
4.5 KiB
Go
138 lines
4.5 KiB
Go
// restic-backups (novox/hq ADR 0214, to-be 43): the machine's backups. One binary, launched by the
|
|
// machine's tool runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It
|
|
// serves the node-backup seat's three verbs — what is backed up, take one now, restore beside the
|
|
// live data — and, beside them, runs the night that happens without anyone asking.
|
|
//
|
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// Seat is the role this module holds.
|
|
const Seat = "node-backup"
|
|
|
|
func say(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "[restic] "+format+"\n", args...)
|
|
}
|
|
|
|
func main() {
|
|
where, err := whereFromEnv()
|
|
if err != nil {
|
|
say("%v", err)
|
|
os.Exit(1)
|
|
}
|
|
b := &Backups{Where: where, Run: execRunner, Now: time.Now, Say: say}
|
|
go nights(b)
|
|
if err := stdio.Serve("", tools(b)); err != nil {
|
|
say("%v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// nights runs at the hour, every module; and at start, if a night was missed — the machine was off
|
|
// or this module was not running at the hour — one soon rather than a day later. Not at once: a
|
|
// machine just started has its stores still coming up.
|
|
func nights(b *Backups) {
|
|
hour := 3
|
|
if h, err := strconv.Atoi(os.Getenv("MESH_BACKUP_HOUR")); err == nil && h >= 0 && h < 24 {
|
|
hour = h
|
|
}
|
|
if missedANight(b.Nights(), time.Now()) {
|
|
time.Sleep(10 * time.Minute)
|
|
night(b)
|
|
}
|
|
for {
|
|
time.Sleep(time.Until(nextNight(time.Now(), hour)))
|
|
night(b)
|
|
}
|
|
}
|
|
|
|
func night(b *Backups) {
|
|
ctx := context.Background()
|
|
outcome, err := b.BackUp(ctx, "")
|
|
if err != nil {
|
|
say("the night did not run: %v", err)
|
|
return
|
|
}
|
|
var failed []string
|
|
for module, n := range outcome {
|
|
if !n.OK {
|
|
failed = append(failed, module)
|
|
}
|
|
}
|
|
if len(failed) > 0 {
|
|
say("the night left %s without a backup", strings.Join(failed, ", "))
|
|
}
|
|
// Sundays, the repository's own integrity with a sample of the data read back.
|
|
if time.Now().Weekday() == time.Sunday {
|
|
if err := b.Check(ctx); err != nil {
|
|
say("the repository does NOT check out: %v", err)
|
|
} else {
|
|
say("the repository checks out")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- the seat's verbs --------------------------------------------------------------------------
|
|
|
|
func str(description string) map[string]any {
|
|
return map[string]any{"type": "string", "description": description}
|
|
}
|
|
|
|
func arg(a map[string]any, k string) string {
|
|
v, _ := a[k].(string)
|
|
return strings.TrimSpace(v)
|
|
}
|
|
|
|
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
|
// subject.
|
|
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
|
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
|
|
}
|
|
|
|
func tools(b *Backups) []stdio.Tool {
|
|
return []stdio.Tool{
|
|
verb("backed-up", "What this machine backs up: each module, what it declared, its last good night, how many restore points are kept.",
|
|
map[string]any{"module": str("one module (optional)")},
|
|
func(a map[string]any) (any, error) { return b.BackedUp(context.Background(), arg(a, "module")) }),
|
|
verb("now", "Take a backup now, of one module or of every module on this machine — before a migration, a retirement or anything else that could go wrong. Answers when it has started; `backed-up` says how it went.",
|
|
map[string]any{"module": str("one module (optional)")},
|
|
func(a map[string]any) (any, error) {
|
|
only := arg(a, "module")
|
|
// A night of a large store outlasts any call; it is started, and its outcome recorded.
|
|
go func() {
|
|
if _, err := b.BackUp(context.Background(), only); err != nil {
|
|
say("a backup asked for now failed: %v", err)
|
|
}
|
|
}()
|
|
started := only
|
|
if started == "" {
|
|
started = "every module on this machine"
|
|
}
|
|
return map[string]any{"started": started, "follow": Seat + ".backed-up"}, nil
|
|
}),
|
|
verb("restore", "Restore one module's data from a restore point BESIDE the live data, never over it: each directory as <path>.restored-<date>. Swapping it in is a person's act.",
|
|
map[string]any{
|
|
"module": str("the module"),
|
|
"snapshot": str("the restore point (the newest when omitted)"),
|
|
"path": str("one of the module's directories (all of them when omitted)"),
|
|
},
|
|
func(a map[string]any) (any, error) {
|
|
module := arg(a, "module")
|
|
if module == "" {
|
|
return nil, fmt.Errorf("module is required")
|
|
}
|
|
return b.Restore(context.Background(), module, arg(a, "snapshot"), arg(a, "path"))
|
|
}),
|
|
}
|
|
}
|