P2R-01: REST + WS rewire against the slim shape

Schedules CRUD now takes {cron, enabled, source_group_ids[]} with cron
parsed via robfig/cron/v3 and group membership scoped to the host.
New source-groups CRUD lives at /api/hosts/{id}/source-groups; delete
refuses with 409 if any schedule still references the group, returning
the schedule list so the UI can prompt 'remove from these schedules
first.' Repo-maintenance GET/PUT manages forget/prune/check cadences
on host_repo_maintenance — no version bump, the server-side ticker
(P2R-06) drives execution.

Per-source-group Run-now (POST /hosts/{id}/source-groups/{gid}/run)
resolves the group's includes/excludes/retention/tag and dispatches a
backup command.run with the new structured CommandRunPayload fields
(Includes/Excludes/Tag). Old per-host /hosts/{id}/run-backup and
/hosts/{id}/init-repo return 410 Gone with a redirect message.

schedule_push.go is rebuilt: buildScheduleSetPayload assembles the
slim wire shape, pushScheduleSetOnConn ships it during the on-hello
window, pushScheduleSetAsync fires after every CRUD mutation, and
dispatchScheduledJob handles agent schedule.fire by iterating the
schedule's source groups and dispatching one backup per group with
actor_kind=schedule and scheduled_id pointing at the schedule.

Auto-init at first WS connect: when the host has repo creds bound and
no init job in its history, server dispatches restic init. Restic's
'config file already exists' soft-success means re-runs against an
existing repo no-op; we don't auto-retry on failure (operator triggers
re-init manually via the danger zone in P2R-09).

api.Schedule drops Kind/Paths/Excludes/Tags/RetentionPolicy/Manual etc.
in favour of {id, cron, enabled, source_groups: [...]}. The agent
scheduler stops checking sch.Manual; cmd/agent's backup dispatch reads
Includes/Excludes/Tag instead of Args.

Tests cover the new HTTP surface end-to-end: source-groups CRUD with
in-use refusal, schedule validation (bad cron / missing groups /
foreign group), repo-maintenance auto-seed and validation, the 410
route, and buildScheduleSetPayload's wire-shape correctness. Full
suite passes; smoke env exercises auto-init dispatch on hello,
async push after schedule create, and per-source-group Run-now
landing the right paths/excludes/tag at the agent.
This commit is contained in:
2026-05-03 10:56:40 +01:00
parent 337dcc0f0f
commit d000fe7ec1
18 changed files with 1564 additions and 101 deletions
+40 -22
View File
@@ -66,13 +66,25 @@ const (
)
// CommandRunPayload is the server → agent dispatch for a run-now job.
// RetentionPolicy is populated for kind=forget jobs (raw JSON so the
// agent doesn't need to share the typed struct definition with the
// server's store package).
//
// For kind=backup, Includes/Excludes/Tag are populated from the source
// group the operator (or schedule) targeted; the agent runs one restic
// backup invocation per command.run, tagging the snapshot with Tag (=
// the source group's name) so retention can target it later via
// `restic forget --tag`.
//
// For kind=forget, RetentionPolicy is the typed keep-* set as raw JSON
// (the agent doesn't share the store package's typed struct).
//
// Args is preserved as a generic free-form slice for kinds that don't
// fit the structured fields (e.g. unlock takes none; init takes none).
type CommandRunPayload struct {
JobID string `json:"job_id"`
Kind JobKind `json:"kind"`
Args []string `json:"args,omitempty"`
Includes []string `json:"includes,omitempty"`
Excludes []string `json:"excludes,omitempty"`
Tag string `json:"tag,omitempty"`
RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"`
}
@@ -171,30 +183,36 @@ type RepoStatsPayload struct {
LockState string `json:"lock_state"` // locked|unlocked
}
// Schedule is the agent-facing view of a Schedule row. (Server-side
// CRUD shapes live in the http handlers; this is what gets pushed.)
// Schedule is the agent-facing view of a slim Schedule row plus its
// resolved bundle of source groups. The agent's cron only needs to know
// when to fire (CronExpr + Enabled) and which schedule fired (ID); the
// SourceGroups are carried for forensic logs and so a future agent that
// elects to dispatch jobs locally has the data, but the server-side
// dispatch path uses the schedule's group list directly. Manual
// schedules are gone — Run-now targets a source group, not a schedule.
type Schedule struct {
ID string `json:"id"`
Kind JobKind `json:"kind"`
CronExpr string `json:"cron_expr"`
Paths []string `json:"paths,omitempty"`
Excludes []string `json:"excludes,omitempty"`
Tags []string `json:"tags,omitempty"`
RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"`
Options json.RawMessage `json:"options,omitempty"`
PreHook string `json:"pre_hook,omitempty"`
PostHook string `json:"post_hook,omitempty"`
Enabled bool `json:"enabled"`
// Manual schedules are not added to the agent's local cron — they
// fire only when the operator clicks a Run-now button. The agent
// can ignore them entirely; we ship them in the payload only so
// the operator can edit them on a still-disconnected agent.
Manual bool `json:"manual,omitempty"`
ID string `json:"id"`
CronExpr string `json:"cron_expr"`
Enabled bool `json:"enabled"`
SourceGroups []ScheduleSourceGroup `json:"source_groups,omitempty"`
}
// ScheduleSourceGroup is the resolved-at-push-time view of a source
// group attached to a schedule. The agent doesn't need source_group_id
// — Name is the snapshot tag and is unique per host.
type ScheduleSourceGroup struct {
Name string `json:"name"`
Includes []string `json:"includes,omitempty"`
Excludes []string `json:"excludes,omitempty"`
RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"`
RetryMax int `json:"retry_max,omitempty"`
RetryBackoffSeconds int `json:"retry_backoff_seconds,omitempty"`
}
// ScheduleSetPayload — server pushes the full canonical schedule list
// for a host. Agent reconciles its local cron and replies with
// ScheduleAckPayload carrying the same Version.
// ScheduleAckPayload carrying the same Version. An empty Schedules
// list is a valid push that disables every cron entry.
type ScheduleSetPayload struct {
Version int64 `json:"version"`
Schedules []Schedule `json:"schedules"`