agent: RunPrune/RunCheck/RunUnlock + reportStats + admin-cred slot dispatch

Extract resticEnv/sendStarted/streamHandler/sendFinished helpers to remove
boilerplate duplication across Run* methods. Add RunPrune (ships repo.stats
with LastPruneAt before job.finished), RunCheck (ships stats with
LastCheckStatus/LockPresent regardless of outcome), RunUnlock (ships
LockPresent=false on success), and reportStats (fills size fields via
RunStats when caller didn't populate them).

Wire JobPrune/JobCheck/JobUnlock into the dispatcher switch; teach
MsgConfigUpdate about the Slot discriminator for admin vs repo creds;
add strconv import for subset-pct parsing.
This commit is contained in:
2026-05-03 22:39:23 +01:00
parent c1237583bd
commit 57bf9690f2
3 changed files with 554 additions and 139 deletions
+103 -23
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"os"
"os/signal"
"strconv"
"syscall"
"time"
@@ -199,32 +200,68 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S
case api.MsgConfigUpdate:
var p api.ConfigUpdatePayload
_ = env.UnmarshalPayload(&p)
// Merge with whatever's already in secrets.enc — empty fields
// in the push mean "leave alone." Atomic write underneath.
cur, err := d.secrets.Load()
if err != nil {
slog.Error("ws agent: load secrets for merge", "err", err)
return nil
slot := p.Slot
if slot == "" {
slot = "repo"
}
changed := false
if p.RepoURL != "" && p.RepoURL != cur.URL {
cur.URL = p.RepoURL
changed = true
}
if p.RepoUsername != "" && p.RepoUsername != cur.Username {
cur.Username = p.RepoUsername
changed = true
}
if p.RepoPassword != "" && p.RepoPassword != cur.Password {
cur.Password = p.RepoPassword
changed = true
}
if changed {
if err := d.secrets.Save(cur); err != nil {
slog.Error("ws agent: persist secrets", "err", err)
switch slot {
case "repo":
// Merge with whatever's already in secrets.enc — empty fields
// in the push mean "leave alone." Atomic write underneath.
cur, err := d.secrets.Load()
if err != nil {
slog.Error("ws agent: load secrets for merge", "err", err)
return nil
}
slog.Info("ws agent: repo credentials updated via config.update")
changed := false
if p.RepoURL != "" && p.RepoURL != cur.URL {
cur.URL = p.RepoURL
changed = true
}
if p.RepoUsername != "" && p.RepoUsername != cur.Username {
cur.Username = p.RepoUsername
changed = true
}
if p.RepoPassword != "" && p.RepoPassword != cur.Password {
cur.Password = p.RepoPassword
changed = true
}
if changed {
if err := d.secrets.Save(cur); err != nil {
slog.Error("ws agent: persist secrets", "err", err)
return nil
}
slog.Info("ws agent: repo credentials updated via config.update")
}
case "admin":
cur, err := d.secrets.LoadAdmin()
if err != nil && !errors.Is(err, secrets.ErrNoAdmin) {
slog.Error("ws agent: load admin secrets", "err", err)
return nil
}
// ErrNoAdmin is not an error here — we are creating the slot.
changed := false
if p.RepoURL != "" && p.RepoURL != cur.URL {
cur.URL = p.RepoURL
changed = true
}
if p.RepoUsername != "" && p.RepoUsername != cur.Username {
cur.Username = p.RepoUsername
changed = true
}
if p.RepoPassword != "" && p.RepoPassword != cur.Password {
cur.Password = p.RepoPassword
changed = true
}
if changed {
if err := d.secrets.SaveAdmin(cur); err != nil {
slog.Error("ws agent: persist admin secrets", "err", err)
return nil
}
slog.Info("ws agent: admin credentials updated via config.update")
}
default:
slog.Warn("ws agent: unknown config.update slot, ignoring", "slot", p.Slot)
}
case api.MsgAgentUpdateAvail:
@@ -318,6 +355,49 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc
}
slog.Info("agent: forget job complete", "job_id", p.JobID)
}()
case api.JobPrune:
// Prune may require admin creds (delete authority on rest-server).
runCreds := creds
if p.RequiresAdminCreds {
ac, err := d.secrets.LoadAdmin()
if err != nil {
return fmt.Errorf("prune: admin creds not configured (server didn't push them): %w", err)
}
if ac.Empty() {
return fmt.Errorf("prune: admin creds incomplete")
}
runCreds = ac
}
prr := runner.New(runner.Config{
ResticBin: d.resticBin,
RepoURL: runCreds.URL,
RepoUsername: runCreds.Username,
RepoPassword: runCreds.Password,
}, tx, time.Second)
slog.Info("agent: accepting prune job", "job_id", p.JobID, "admin_creds", p.RequiresAdminCreds)
go func() {
if err := prr.RunPrune(ctx, p.JobID); err != nil {
slog.Warn("agent: prune job failed", "job_id", p.JobID, "err", err)
}
}()
case api.JobCheck:
subset := 0
if len(p.Args) > 0 {
subset, _ = strconv.Atoi(p.Args[0])
}
slog.Info("agent: accepting check job", "job_id", p.JobID, "subset_pct", subset)
go func() {
if err := r.RunCheck(ctx, p.JobID, subset); err != nil {
slog.Warn("agent: check job failed", "job_id", p.JobID, "err", err)
}
}()
case api.JobUnlock:
slog.Info("agent: accepting unlock job", "job_id", p.JobID)
go func() {
if err := r.RunUnlock(ctx, p.JobID); err != nil {
slog.Warn("agent: unlock job failed", "job_id", p.JobID, "err", err)
}
}()
default:
return fmt.Errorf("kind %q not implemented yet (Phase 2 lands the rest)", p.Kind)
}