P2-05: forget command with retention policy

End-to-end forget plumbing — operator can create a forget schedule
with keep-* values, agent runs restic forget --keep-* … on the
schedule's cron (or via per-row Run-now), snapshot list shrinks,
UI updates.

* api.CommandRunPayload gains retention_policy json.RawMessage so
  the agent doesn't need a typed copy of the server-side struct.
* restic.ForgetPolicy mirrors restic's --keep-* flags. Empty()
  reports zero dimensions; restic wrapper RunForget refuses to
  run an empty policy (would delete every snapshot). Does NOT
  pass --prune — pruning lives behind a separate admin-only
  credential (P2-06); forget just rewrites the snapshot index.
* runner.RunForget mirrors RunBackup's envelope shape so the
  live log viewer works without special-casing. On success
  triggers reportSnapshots (forget shrinks the index, the host's
  snapshot count almost certainly changed).
* cmd/agent dispatcher handles MsgCommandRun with kind=forget,
  decodes RetentionPolicy from the wire, builds restic.ForgetPolicy.
* Server dispatchScheduleNow marshals the schedule's
  RetentionPolicy into the wire payload for kind=forget jobs.
  Refuses to dispatch a forget schedule with empty retention.
* validateSchedule rejects kind=forget without at least one keep-*
  dimension (new error code: missing_retention).
* UI schedule edit form gains a Kind dropdown (backup or forget;
  immutable on edit). Paths block toggles by kind via inline
  data-kind attributes. Form help-text explains the prune
  separation.

Other kinds (prune, check, unlock) deferred to P2-06..08; the
Kind dropdown only offers backup and forget today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 14:07:42 +01:00
parent 457a7e049c
commit 6a171596f1
10 changed files with 282 additions and 30 deletions
+82
View File
@@ -148,6 +148,88 @@ func (e Env) RunBackup(ctx context.Context, paths, excludes, tags []string, hand
return summary, nil
}
// 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.
type ForgetPolicy struct {
KeepLast *int
KeepHourly *int
KeepDaily *int
KeepWeekly *int
KeepMonthly *int
KeepYearly *int
}
// args returns the --keep-* CLI flags this policy translates into.
// Empty slice if the policy is empty (caller should reject before
// calling RunForget — restic refuses to forget without any keep-*).
func (p ForgetPolicy) args() []string {
out := []string{}
add := func(flag string, v *int) {
if v != nil {
out = append(out, flag, fmt.Sprintf("%d", *v))
}
}
add("--keep-last", p.KeepLast)
add("--keep-hourly", p.KeepHourly)
add("--keep-daily", p.KeepDaily)
add("--keep-weekly", p.KeepWeekly)
add("--keep-monthly", p.KeepMonthly)
add("--keep-yearly", p.KeepYearly)
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.
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)")
}
args := append([]string{"forget", "--json"}, policy.args()...)
cmd := exec.CommandContext(ctx, e.Bin, args...)
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("restic forget: stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("restic forget: stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("restic forget: start: %w", err)
}
done := make(chan error, 2)
go func() { done <- pumpPlain(stdout, "stdout", handle) }()
go func() { done <- pumpPlain(stderr, "stderr", handle) }()
for i := 0; i < 2; i++ {
if err := <-done; err != nil && handle != nil {
handle("event", fmt.Sprintf("pump error: %v", err), nil)
}
}
if werr := cmd.Wait(); werr != nil {
return fmt.Errorf("restic forget: %w", werr)
}
return nil
}
// RunInit executes `restic init` against the configured repo. Returns
// nil on success. Restic init's output is small and not JSON-rich;
// we tee stdout/stderr verbatim through handle so the operator sees