server: maintenance ticker drives forget/prune/check on cadence

Wires a 60s server-side ticker to the pure-logic maintenance.Decide
introduced in the previous commit. Decisions flow through a new
DispatchMaintenance method on *Server, which:

  - skips offline hosts (no pending_runs queueing — maintenance is
    not a backup, missed fires shouldn't pile up)
  - silently skips prune when admin creds aren't bound
  - pushes admin creds before prune, then dispatches with
    RequiresAdminCreds=true (same as operator-driven prune)
  - persists job rows with actor_kind="system"

Reshapes the forget wire payload from a single RetentionPolicy to a
ForgetGroups list (one tag + per-group keep-* per source group). The
agent walks the groups and runs `restic forget --tag <name> --keep-*`
once per group. Dead-code removed: CommandRunPayload.RetentionPolicy,
the old forget JSON-decode in cmd/agent, and the single-policy form of
restic.RunForget.
This commit is contained in:
2026-05-03 23:40:35 +01:00
parent ae96983877
commit 14b703be58
8 changed files with 559 additions and 62 deletions
+37 -19
View File
@@ -151,8 +151,7 @@ func (e Env) RunBackup(ctx context.Context, paths, excludes, tags []string, hand
}
// ForgetPolicy mirrors restic forget's --keep-* flags. All optional;
// nil/zero means "don't pass that flag." Caller passes whatever the
// schedule's RetentionPolicy carries.
// nil/zero means "don't pass that flag."
type ForgetPolicy struct {
KeepLast *int
KeepHourly *int
@@ -181,30 +180,49 @@ func (p ForgetPolicy) args() []string {
return out
}
// Empty reports whether no retention dimensions are set. restic
// forget refuses to run without at least one keep-* flag (it would
// delete every snapshot), so the agent rejects empty policies before
// invoking restic.
// Empty reports whether no retention dimensions are set.
func (p ForgetPolicy) Empty() bool {
return p.KeepLast == nil && p.KeepHourly == nil &&
p.KeepDaily == nil && p.KeepWeekly == nil &&
p.KeepMonthly == nil && p.KeepYearly == nil
}
// RunForget executes `restic forget --keep-* … --json` against the
// configured repo. Does NOT pass --prune — pruning lives behind a
// separate, admin-only credential (see spec §4.3 / P2-06). Restic
// just rewrites the snapshot index; the actual data deletion waits
// for the next prune. Returns nil on a clean exit.
func (e Env) RunForget(ctx context.Context, policy ForgetPolicy, handle LineHandler) error {
if policy.Empty() {
return fmt.Errorf("restic forget: refusing to run with empty retention policy (would delete every snapshot)")
// ForgetGroup is one (tag, retention-policy) pair fed to RunForget.
// The wrapper invokes `restic forget --tag <Tag> --keep-* …` per
// group so retention can be targeted at a single source-group's
// snapshots without disturbing snapshots tagged for other groups.
type ForgetGroup struct {
Tag string
Policy ForgetPolicy
}
// RunForget executes one `restic forget --tag <Tag> --keep-* …`
// invocation per group. Does NOT pass --prune — pruning lives behind
// a separate admin-only credential (see spec §4.3 / P2-06). Restic
// rewrites the snapshot index; the actual data deletion waits for
// the next prune. Empty groups slice is rejected (would be a no-op);
// any group with an empty policy is rejected (restic forget without
// any keep-* would delete every snapshot in the tagged set).
// Returns the first error encountered, or nil when every group runs
// to a clean exit.
func (e Env) RunForget(ctx context.Context, groups []ForgetGroup, handle LineHandler) error {
if len(groups) == 0 {
return fmt.Errorf("restic forget: refusing to run with no groups (would be a no-op)")
}
args := append([]string{"forget", "--json"}, policy.args()...)
cmd := exec.CommandContext(ctx, e.Bin, args...)
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
return runWithPump(cmd, handle)
for _, g := range groups {
if g.Policy.Empty() {
return fmt.Errorf("restic forget: group %q has empty retention policy (would delete every snapshot)", g.Tag)
}
args := []string{"forget", "--json", "--tag", g.Tag}
args = append(args, g.Policy.args()...)
cmd := exec.CommandContext(ctx, e.Bin, args...)
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
if err := runWithPump(cmd, handle); err != nil {
return err
}
}
return nil
}
// RunInit executes `restic init` against the configured repo. Returns