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
+7
View File
@@ -2,6 +2,13 @@
Project-specific rules for Claude when working in this repo.
## No `Co-Authored-By` trailers on commits
Don't add `Co-Authored-By: Claude ...` (or any other co-author
trailer) to commit messages in this repo. The README will make it
plain that the project is heavily spec-coded, so per-commit
attribution is just noise.
## After building a new binary, also stage it for the smoke env
The smoke / dev environment runs the server out of `bin/` directly,
+14 -4
View File
@@ -260,12 +260,22 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc
switch p.Kind {
case api.JobBackup:
// Agent.Args carries [paths...]. Excludes/tags are not yet
// surfaced over the wire; they come with P2 schedule support.
// Includes/Excludes/Tag come from the source group resolved
// server-side. Args is preserved for backwards compatibility:
// if the server sends only Args (older shape) we fall back to
// treating it as the paths list with no tag.
paths := p.Includes
if len(paths) == 0 {
paths = p.Args
}
var tags []string
if p.Tag != "" {
tags = []string{p.Tag}
}
slog.Info("agent: accepting backup job",
"job_id", p.JobID, "paths", p.Args)
"job_id", p.JobID, "paths", paths, "excludes", p.Excludes, "tag", p.Tag)
go func() {
if err := r.RunBackup(ctx, p.JobID, p.Args, nil, nil); err != nil {
if err := r.RunBackup(ctx, p.JobID, paths, p.Excludes, tags); err != nil {
slog.Warn("agent: backup job failed", "job_id", p.JobID, "err", err)
return
}
-7
View File
@@ -88,13 +88,6 @@ func (s *Scheduler) Apply(payload api.ScheduleSetPayload, tx Sender) {
if !sch.Enabled {
continue
}
// Manual schedules carry paths/retention/etc. but have no
// cron — they only fire via operator-driven run-now (which
// the server resolves directly via dispatchScheduledJob).
// Skip without warning: they're a normal data shape.
if sch.Manual {
continue
}
// Capture by value so the closure doesn't share id across iters.
entry := sch
_, err := c.AddFunc(entry.CronExpr, func() {
+4 -4
View File
@@ -39,7 +39,7 @@ func TestApplyEmitsAck(t *testing.T) {
s.Apply(api.ScheduleSetPayload{
Version: 7,
Schedules: []api.Schedule{
{ID: "s1", Kind: api.JobBackup, CronExpr: "@hourly", Enabled: true},
{ID: "s1", CronExpr: "@hourly", Enabled: true},
},
}, tx)
@@ -72,7 +72,7 @@ func TestApplyTickFiresScheduleFire(t *testing.T) {
s.Apply(api.ScheduleSetPayload{
Version: 1,
Schedules: []api.Schedule{
{ID: "every-second", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: true},
{ID: "every-second", CronExpr: "@every 1s", Enabled: true},
},
}, tx)
@@ -102,7 +102,7 @@ func TestApplyDisabledEntriesSkipped(t *testing.T) {
s.Apply(api.ScheduleSetPayload{
Version: 1,
Schedules: []api.Schedule{
{ID: "off", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: false},
{ID: "off", CronExpr: "@every 1s", Enabled: false},
},
}, tx)
@@ -125,7 +125,7 @@ func TestApplyReplacesPriorState(t *testing.T) {
s.Apply(api.ScheduleSetPayload{
Version: 1,
Schedules: []api.Schedule{
{ID: "old", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: true},
{ID: "old", CronExpr: "@every 1s", Enabled: true},
},
}, tx)
+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"`
+61
View File
@@ -199,6 +199,67 @@ func (s *Server) onAgentHello(ctx context.Context, hostID string, conn *ws.Conn)
// drop any cron entries left over from a previous deployment.
// Always runs, even when the host has no repo credentials yet.
s.pushScheduleSetOnConn(ctx, hostID, conn)
// Auto-init the repo if we've never landed a successful init job
// against this host. Restic treats "config file already exists"
// as a soft success, so re-enrolment against a populated repo
// just no-ops. Skipped silently when the host has no creds yet —
// the next hello after the operator binds creds will dispatch.
s.maybeAutoInit(ctx, hostID, conn)
}
// maybeAutoInit dispatches a `restic init` job iff the host has no
// successful init in its history AND repo creds are bound (without
// them the runner can't talk to the repo). We rely on Restic's
// idempotent init for re-runs.
func (s *Server) maybeAutoInit(ctx context.Context, hostID string, conn *ws.Conn) {
if _, err := s.deps.Store.GetHostCredentials(ctx, hostID); err != nil {
// No creds bound yet — operator hasn't supplied them. The next
// hello after creds land will pick this up.
return
}
already, err := s.deps.Store.HasJobOfKind(ctx, hostID, string(api.JobInit))
if err != nil {
slog.Warn("auto-init: check job history", "host_id", hostID, "err", err)
return
}
if already {
return
}
jobID := ulid.Make().String()
now := time.Now().UTC()
if err := s.deps.Store.CreateJob(ctx, store.Job{
ID: jobID,
HostID: hostID,
Kind: string(api.JobInit),
ActorKind: "system",
CreatedAt: now,
}); err != nil {
slog.Warn("auto-init: persist job", "host_id", hostID, "err", err)
return
}
env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{
JobID: jobID,
Kind: api.JobInit,
})
if err != nil {
slog.Warn("auto-init: marshal command.run", "host_id", hostID, "err", err)
return
}
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := conn.Send(sendCtx, env); err != nil {
slog.Warn("auto-init: send command.run", "host_id", hostID, "err", err)
return
}
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
ID: ulid.Make().String(),
Actor: "system",
Action: "host.auto_init",
TargetKind: ptr("host"),
TargetID: &hostID,
TS: now,
})
slog.Info("auto-init: dispatched", "host_id", hostID, "job_id", jobID)
}
// pushRepoCredsOnHello loads + decrypts + sends the host's repo
+26 -9
View File
@@ -64,6 +64,19 @@ func (s *Server) handleRunNow(w stdhttp.ResponseWriter, r *stdhttp.Request) {
// flash banner + redirect.
func (s *Server) dispatchJob(ctx context.Context, user *store.User,
hostID string, kind api.JobKind, args []string,
) (res runNowResponse, status int, code, msg string) {
return s.dispatchJobWithPayload(ctx, user, hostID, kind, api.CommandRunPayload{
Kind: kind,
Args: args,
})
}
// dispatchJobWithPayload is dispatchJob's variant that lets callers
// fill in structured fields (Includes/Excludes/Tag/RetentionPolicy)
// — used by the per-source-group Run-now path. JobID is filled in
// here; callers leave it zero on the input payload.
func (s *Server) dispatchJobWithPayload(ctx context.Context, user *store.User,
hostID string, kind api.JobKind, payload api.CommandRunPayload,
) (res runNowResponse, status int, code, msg string) {
if !validJobKind(kind) {
return res, stdhttp.StatusBadRequest, "invalid_kind",
@@ -80,22 +93,26 @@ func (s *Server) dispatchJob(ctx context.Context, user *store.User,
jobID := ulid.Make().String()
now := time.Now().UTC()
var actorID *string
actor := "system"
if user != nil {
actor = "user"
actorID = &user.ID
}
if err := s.deps.Store.CreateJob(ctx, store.Job{
ID: jobID,
HostID: host.ID,
Kind: string(kind),
ActorKind: "user",
ActorID: &user.ID,
ActorKind: actor,
ActorID: actorID,
CreatedAt: now,
}); err != nil {
return res, stdhttp.StatusInternalServerError, "internal", ""
}
env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{
JobID: jobID,
Kind: kind,
Args: args,
})
payload.JobID = jobID
payload.Kind = kind
env, err := api.Marshal(api.MsgCommandRun, jobID, payload)
if err != nil {
return res, stdhttp.StatusInternalServerError, "internal", ""
}
@@ -105,8 +122,8 @@ func (s *Server) dispatchJob(ctx context.Context, user *store.User,
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
ID: ulid.Make().String(),
UserID: &user.ID,
Actor: "user",
UserID: actorID,
Actor: actor,
Action: "job.run_now",
TargetKind: ptr("job"),
TargetID: &jobID,
+482
View File
@@ -0,0 +1,482 @@
// p2r01_test.go — HTTP-level coverage for the slim-shape REST surface
// landed in P2R-01: schedules, source-groups, repo-maintenance, the
// per-source-group Run-now endpoint, schedule_push reconciliation,
// and auto-init at hello.
package http
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
stdhttp "net/http"
"strings"
"testing"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// loginAsAdmin creates an admin user + a session in the store and
// returns a cookie ready to attach to outgoing requests.
func loginAsAdmin(t *testing.T, st *store.Store) *stdhttp.Cookie {
t.Helper()
ctx := context.Background()
uid := ulid.Make().String()
hash, _ := auth.HashPassword("very-long-test-password")
if err := st.CreateUser(ctx, store.User{
ID: uid, Username: "tester-" + uid[:6],
PasswordHash: hash, Role: store.RoleAdmin,
CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("create user: %v", err)
}
tok, _ := auth.NewToken()
if err := st.CreateSession(ctx, store.Session{
UserID: uid,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().Add(time.Hour).UTC(),
}, auth.HashToken(tok)); err != nil {
t.Fatalf("create session: %v", err)
}
return &stdhttp.Cookie{Name: sessionCookieName, Value: tok}
}
// makeHost inserts a minimal Host row directly via the store. Used by
// HTTP-level tests that don't want to go through the full enrollment
// path. Returns the host id.
func makeHost(t *testing.T, st *store.Store, name string) string {
t.Helper()
id := ulid.Make().String()
if err := st.CreateHost(context.Background(), store.Host{
ID: id, Name: name, OS: "linux", Arch: "amd64",
ProtocolVersion: api.CurrentProtocolVersion,
EnrolledAt: time.Now().UTC(),
}, "tokhash-"+id, ""); err != nil {
t.Fatalf("create host: %v", err)
}
return id
}
// doJSON issues a JSON request with the given method and body, returns
// status + decoded JSON map (nil on empty body).
func doJSON(t *testing.T, baseURL, method, path string, body any, cookie *stdhttp.Cookie) (int, map[string]any) {
t.Helper()
var rdr io.Reader
if body != nil {
raw, _ := json.Marshal(body)
rdr = bytes.NewReader(raw)
}
req, err := stdhttp.NewRequest(method, baseURL+path, rdr)
if err != nil {
t.Fatalf("new req: %v", err)
}
if rdr != nil {
req.Header.Set("Content-Type", "application/json")
}
if cookie != nil {
req.AddCookie(cookie)
}
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if len(raw) == 0 {
return res.StatusCode, nil
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
// Non-JSON (HTMX action paths return plain text on error).
return res.StatusCode, map[string]any{"raw": string(raw)}
}
return res.StatusCode, out
}
// ----- source-groups ------------------------------------------------
func TestSourceGroupsCRUD(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "sg-host")
// Empty list at start.
status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/source-groups", nil, cookie)
if status != 200 {
t.Fatalf("list status: %d", status)
}
if got := body["source_groups"].([]any); len(got) != 0 {
t.Fatalf("expected empty list, got %d", len(got))
}
// Create.
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
map[string]any{
"name": "etc",
"includes": []string{"/etc"},
"excludes": []string{"/etc/shadow"},
"retention_policy": map[string]int{
"keep_daily": 7,
},
"retry_max": 3,
"retry_backoff_seconds": 60,
}, cookie)
if status != 201 {
t.Fatalf("create status: %d, body: %+v", status, body)
}
gid, _ := body["id"].(string)
if gid == "" {
t.Fatalf("create: no id returned: %+v", body)
}
// Duplicate name → 409.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
map[string]any{"name": "etc", "includes": []string{"/x"}}, cookie)
if status != 409 {
t.Errorf("duplicate name: want 409, got %d", status)
}
// Update — rename + add another include.
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/source-groups/"+gid,
map[string]any{
"name": "system",
"includes": []string{"/etc", "/var/log"},
"retention_policy": map[string]int{
"keep_daily": 14,
"keep_weekly": 4,
},
}, cookie)
if status != 200 {
t.Fatalf("update status: %d, body: %+v", status, body)
}
if got := body["name"]; got != "system" {
t.Errorf("rename: got %v want system", got)
}
// Delete.
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
if status != 204 {
t.Errorf("delete status: %d", status)
}
// Already gone.
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
if status != 404 {
t.Errorf("delete-after-delete: want 404, got %d", status)
}
}
func TestSourceGroupDeleteRefusesIfInUse(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "sg-inuse-host")
// Create a group via the store directly.
gid := ulid.Make().String()
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/home"},
}); err != nil {
t.Fatalf("create group: %v", err)
}
// Attach a schedule.
sid := ulid.Make().String()
if err := st.CreateSchedule(context.Background(), &store.Schedule{
ID: sid, HostID: hostID,
CronExpr: "0 3 * * *", Enabled: true,
SourceGroupIDs: []string{gid},
}); err != nil {
t.Fatalf("create schedule: %v", err)
}
status, body := doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
if status != 409 {
t.Fatalf("want 409, got %d body=%+v", status, body)
}
if body["code"] != "group_in_use" {
t.Errorf("wrong code: %+v", body)
}
}
// ----- schedules ----------------------------------------------------
func TestSchedulesCRUDValidation(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "sched-host")
// Bad cron → 400.
status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "not-a-cron", "enabled": true,
"source_group_ids": []string{"x"}}, cookie)
if status != 400 {
t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body)
}
// Missing groups → 400.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{}}, cookie)
if status != 400 {
t.Errorf("missing groups: want 400, got %d", status)
}
// Group not on host → 400.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{"non-existent"}}, cookie)
if status != 400 {
t.Errorf("bogus group: want 400, got %d", status)
}
// Create a real group.
gid := ulid.Make().String()
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"},
}); err != nil {
t.Fatalf("group: %v", err)
}
// Happy create.
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{gid}}, cookie)
if status != 201 {
t.Fatalf("create: %d body=%+v", status, body)
}
sid, _ := body["id"].(string)
if sid == "" {
t.Fatalf("no id: %+v", body)
}
// List.
status, body = doJSON(t, url, "GET", "/api/hosts/"+hostID+"/schedules", nil, cookie)
if status != 200 {
t.Fatalf("list: %d", status)
}
rows, _ := body["schedules"].([]any)
if len(rows) != 1 {
t.Fatalf("expected 1 schedule, got %d", len(rows))
}
// Update — change cron, keep group.
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid,
map[string]any{"cron": "@hourly", "enabled": false,
"source_group_ids": []string{gid}}, cookie)
if status != 200 {
t.Fatalf("update: %d body=%+v", status, body)
}
if body["cron"] != "@hourly" || body["enabled"] != false {
t.Errorf("update fields: %+v", body)
}
// Delete.
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/schedules/"+sid, nil, cookie)
if status != 204 {
t.Errorf("delete: %d", status)
}
}
// ----- repo-maintenance --------------------------------------------
func TestRepoMaintenanceGetSeedsAndPutValidates(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "maint-host")
// GET on a host that hasn't had the row seeded auto-creates one.
status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/repo-maintenance", nil, cookie)
if status != 200 {
t.Fatalf("get: %d body=%+v", status, body)
}
if body["host_id"] != hostID {
t.Errorf("host_id mismatch: %+v", body)
}
// PUT with bad cron.
status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
map[string]any{
"forget_cron": "junk", "prune_cron": "@weekly",
"check_cron": "@monthly", "check_subset_pct": 10,
}, cookie)
if status != 400 {
t.Errorf("bad cron: want 400, got %d", status)
}
// PUT with subset out of range.
status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
map[string]any{
"forget_cron": "@daily", "prune_cron": "@weekly",
"check_cron": "@monthly", "check_subset_pct": 200,
}, cookie)
if status != 400 {
t.Errorf("bad subset: want 400, got %d", status)
}
// Happy PUT.
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
map[string]any{
"forget_cron": "@daily",
"forget_enabled": true,
"prune_cron": "@weekly",
"prune_enabled": true,
"check_cron": "@monthly",
"check_enabled": false,
"check_subset_pct": 25,
}, cookie)
if status != 200 {
t.Fatalf("happy put: %d body=%+v", status, body)
}
if body["forget_cron"] != "@daily" || body["check_subset_pct"] != float64(25) {
t.Errorf("fields: %+v", body)
}
}
// ----- 410 Gone on retired routes ----------------------------------
func TestPerHostRunBackupReturns410(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "gone-host")
req, _ := stdhttp.NewRequest("POST", url+"/hosts/"+hostID+"/run-backup", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusGone {
t.Errorf("want 410, got %d", res.StatusCode)
}
}
// ----- schedule_push payload ---------------------------------------
func TestBuildScheduleSetPayload(t *testing.T) {
t.Parallel()
srv, _, st := newTestServerWithHub(t)
hostID := makeHost(t, st, "push-host")
gid := ulid.Make().String()
keepDaily := 7
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
ID: gid, HostID: hostID, Name: "default",
Includes: []string{"/etc", "/home"},
Excludes: []string{"/etc/shadow"},
RetentionPolicy: store.RetentionPolicy{KeepDaily: &keepDaily},
RetryMax: 2, RetryBackoffSeconds: 30,
}); err != nil {
t.Fatalf("group: %v", err)
}
sid := ulid.Make().String()
if err := st.CreateSchedule(context.Background(), &store.Schedule{
ID: sid, HostID: hostID,
CronExpr: "0 3 * * *", Enabled: true,
SourceGroupIDs: []string{gid},
}); err != nil {
t.Fatalf("schedule: %v", err)
}
payload, err := srv.buildScheduleSetPayload(context.Background(), hostID)
if err != nil {
t.Fatalf("build: %v", err)
}
if payload.Version == 0 {
t.Fatalf("version should be > 0, got %d", payload.Version)
}
if len(payload.Schedules) != 1 {
t.Fatalf("schedules: %d", len(payload.Schedules))
}
entry := payload.Schedules[0]
if entry.ID != sid || entry.CronExpr != "0 3 * * *" || !entry.Enabled {
t.Errorf("schedule fields: %+v", entry)
}
if len(entry.SourceGroups) != 1 {
t.Fatalf("groups in schedule: %d", len(entry.SourceGroups))
}
g := entry.SourceGroups[0]
if g.Name != "default" {
t.Errorf("group name: %s", g.Name)
}
if !equalStrings(g.Includes, []string{"/etc", "/home"}) {
t.Errorf("includes: %v", g.Includes)
}
var rp map[string]any
_ = json.Unmarshal(g.RetentionPolicy, &rp)
if rp["keep_daily"] != float64(7) {
t.Errorf("retention: %+v", rp)
}
}
// ----- per-source-group Run-now -----------------------------------
func TestRunSourceGroupOfflineHost(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "offline-host")
gid := ulid.Make().String()
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"},
}); err != nil {
t.Fatalf("group: %v", err)
}
// JSON path → 503 (host offline).
req, _ := stdhttp.NewRequest("POST",
url+"/hosts/"+hostID+"/source-groups/"+gid+"/run", nil)
req.AddCookie(cookie)
req.Header.Set("Accept", "application/json")
res, _ := stdhttp.DefaultClient.Do(req)
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusServiceUnavailable {
t.Errorf("offline: want 503, got %d", res.StatusCode)
}
}
func TestRunSourceGroupUnknownGroup(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "noh-host")
req, _ := stdhttp.NewRequest("POST",
url+"/hosts/"+hostID+"/source-groups/no-such-gid/run", nil)
req.AddCookie(cookie)
req.Header.Set("Accept", "application/json")
res, _ := stdhttp.DefaultClient.Do(req)
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusNotFound {
t.Errorf("unknown group: want 404, got %d", res.StatusCode)
}
}
// ----- helpers ----------------------------------------------------
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// keep fmt import live — used for occasional debug.
var _ = fmt.Sprintf
var _ = strings.HasPrefix
+145
View File
@@ -0,0 +1,145 @@
// repo_maintenance.go — REST API for /api/hosts/{id}/repo-maintenance.
//
// Cadence rows for the three repo-wide verbs (forget / prune / check).
// Edits do NOT bump host_schedule_version: the server-side maintenance
// ticker drives execution (P2R-06), not the agent's local cron.
package http
import (
"encoding/json"
"errors"
stdhttp "net/http"
"github.com/go-chi/chi/v5"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
type repoMaintenanceView struct {
HostID string `json:"host_id"`
ForgetCron string `json:"forget_cron"`
ForgetEnabled bool `json:"forget_enabled"`
PruneCron string `json:"prune_cron"`
PruneEnabled bool `json:"prune_enabled"`
CheckCron string `json:"check_cron"`
CheckEnabled bool `json:"check_enabled"`
CheckSubsetPct int `json:"check_subset_pct"`
}
func toRepoMaintenanceView(m store.HostRepoMaintenance) repoMaintenanceView {
return repoMaintenanceView{
HostID: m.HostID,
ForgetCron: m.ForgetCron,
ForgetEnabled: m.ForgetEnabled,
PruneCron: m.PruneCron,
PruneEnabled: m.PruneEnabled,
CheckCron: m.CheckCron,
CheckEnabled: m.CheckEnabled,
CheckSubsetPct: m.CheckSubsetPct,
}
}
func (s *Server) handleGetRepoMaintenance(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
return
}
m, err := s.deps.Store.GetRepoMaintenance(r.Context(), hostID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
// Self-heal: seed and return the defaults so the UI never
// has to handle a 404 here. Hosts enrolled before the
// migration may legitimately be missing the row.
if seedErr := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), hostID); seedErr != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
m, err = s.deps.Store.GetRepoMaintenance(r.Context(), hostID)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
} else {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
}
writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(*m))
}
type repoMaintenanceWriteRequest struct {
ForgetCron string `json:"forget_cron"`
ForgetEnabled bool `json:"forget_enabled"`
PruneCron string `json:"prune_cron"`
PruneEnabled bool `json:"prune_enabled"`
CheckCron string `json:"check_cron"`
CheckEnabled bool `json:"check_enabled"`
CheckSubsetPct int `json:"check_subset_pct"`
}
func (s *Server) handleUpdateRepoMaintenance(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
return
}
var req repoMaintenanceWriteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
for label, expr := range map[string]string{
"forget_cron": req.ForgetCron,
"prune_cron": req.PruneCron,
"check_cron": req.CheckCron,
} {
if expr == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", label+" required")
return
}
if _, err := cronParser.Parse(expr); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_cron", label+": "+err.Error())
return
}
}
if req.CheckSubsetPct < 0 || req.CheckSubsetPct > 100 {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_value",
"check_subset_pct must be 0..100")
return
}
// Ensure the row exists (older hosts may pre-date the auto-seed).
if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), hostID); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
m := store.HostRepoMaintenance{
HostID: hostID,
ForgetCron: req.ForgetCron,
ForgetEnabled: req.ForgetEnabled,
PruneCron: req.PruneCron,
PruneEnabled: req.PruneEnabled,
CheckCron: req.CheckCron,
CheckEnabled: req.CheckEnabled,
CheckSubsetPct: req.CheckSubsetPct,
}
if err := s.deps.Store.UpdateRepoMaintenance(r.Context(), &m); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
out, _ := s.deps.Store.GetRepoMaintenance(r.Context(), hostID)
if out != nil {
writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(*out))
return
}
writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(m))
}
+83
View File
@@ -0,0 +1,83 @@
// run_group.go — per-source-group Run-now endpoint.
//
// POST /hosts/{id}/source-groups/{gid}/run dispatches a backup job
// against the resolved includes/excludes/retention/tag of the named
// group. Replaces the old per-host /hosts/{id}/run-backup route (now
// 410 Gone).
package http
import (
"encoding/json"
"errors"
stdhttp "net/http"
"github.com/go-chi/chi/v5"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func (s *Server) handleRunSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
user, ok := s.requireUser(r)
if !ok {
// HTML callers redirect to login; for JSON return 401.
if wantsHTML(r) {
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
return
}
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
groupID := chi.URLParam(r, "gid")
g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
s.runGroupError(w, r, stdhttp.StatusNotFound, "group_not_found",
"source group not found on this host")
return
}
s.runGroupError(w, r, stdhttp.StatusInternalServerError, "internal", "")
return
}
retention, _ := json.Marshal(g.RetentionPolicy)
res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobBackup,
api.CommandRunPayload{
Includes: g.Includes,
Excludes: g.Excludes,
Tag: g.Name,
RetentionPolicy: retention,
})
if code != "" {
s.runGroupError(w, r, status, code, msg)
return
}
if wantsHTML(r) {
// HTMX action: redirect to the live job log so the operator
// sees streaming output immediately.
w.Header().Set("HX-Redirect", "/jobs/"+res.JobID)
w.WriteHeader(stdhttp.StatusNoContent)
return
}
writeJSON(w, stdhttp.StatusAccepted, res)
}
// runGroupError dispatches an error to JSON callers as the standard
// envelope; HTMX callers get a 4xx with a plain text body so the
// browser surfaces it via the existing toast handler.
func (s *Server) runGroupError(w stdhttp.ResponseWriter, r *stdhttp.Request, status int, code, msg string) {
if wantsHTML(r) {
stdhttp.Error(w, msg, status)
return
}
writeJSONError(w, status, code, msg)
}
// wantsHTML keys off HX-Request only. Browsers sending a default
// Accept (or curl's `*/*`) get the JSON shape, which is the safer
// default for non-htmx clients. HTMX always sets HX-Request=true on
// its action POSTs, so the form path is unambiguous.
func wantsHTML(r *stdhttp.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
+194 -13
View File
@@ -1,37 +1,218 @@
// schedule_push.go — server → agent reconciliation push and the
// inbound schedule.fire dispatch.
//
// The slim-schedule wire shape is built here from the (Schedule,
// SourceGroup) pair. Each schedule is sent with its resolved source
// groups inlined so the agent doesn't have to keep its own copy of
// the group catalogue. Cron + enabled drive the agent's local timer;
// when an entry fires the agent ships back a schedule.fire and
// dispatchScheduledJob below resolves the schedule's groups and
// dispatches one backup command.run per group.
package http
import (
"context"
"encoding/json"
"errors"
"log/slog"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// schedule_push.go — server → agent reconciliation push.
//
// Stubbed during the P2 redesign rewrite. The new wire shape (slim
// schedules referencing source groups; groups inline by id at push
// time) lands in Phase 3. Until then on-hello and post-CRUD pushes
// are no-ops; the agent will keep its existing cron entries (none,
// since there are no schedules yet) and the only operator-driven
// jobs flow via run-now once the new UI is wired in Phase 4.
// pushScheduleSetOnConn ships the canonical schedule set straight down
// the freshly-accepted hello connection. Callers are inside the
// hello window — using the conn directly avoids racing the hub's
// register-then-supersede sequence.
func (s *Server) pushScheduleSetOnConn(ctx context.Context, hostID string, conn *ws.Conn) {
slog.Debug("schedule push: stubbed during P2 redesign", "host_id", hostID)
payload, err := s.buildScheduleSetPayload(ctx, hostID)
if err != nil {
slog.Warn("schedule push: build payload", "host_id", hostID, "err", err)
return
}
env, err := api.Marshal(api.MsgScheduleSet, "", payload)
if err != nil {
slog.Warn("schedule push: marshal payload", "host_id", hostID, "err", err)
return
}
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := conn.Send(sendCtx, env); err != nil {
slog.Warn("schedule push on-hello: send", "host_id", hostID, "err", err)
}
}
// pushScheduleSetAsync pushes the latest schedule set to a connected
// agent (via the hub) on a best-effort basis. Mutations call this
// after a successful CRUD; offline agents pick the new version up on
// next reconnect via pushScheduleSetOnConn.
func (s *Server) pushScheduleSetAsync(hostID string) {
slog.Debug("schedule push async: stubbed during P2 redesign", "host_id", hostID)
if s.deps.Hub == nil || !s.deps.Hub.Connected(hostID) {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
payload, err := s.buildScheduleSetPayload(ctx, hostID)
if err != nil {
slog.Warn("schedule push async: build payload", "host_id", hostID, "err", err)
return
}
env, err := api.Marshal(api.MsgScheduleSet, "", payload)
if err != nil {
slog.Warn("schedule push async: marshal", "host_id", hostID, "err", err)
return
}
if err := s.deps.Hub.Send(ctx, hostID, env); err != nil {
slog.Debug("schedule push async: send", "host_id", hostID, "err", err)
}
}()
}
// buildScheduleSetPayload assembles the canonical wire shape: every
// schedule for the host with its source groups resolved inline.
func (s *Server) buildScheduleSetPayload(ctx context.Context, hostID string) (api.ScheduleSetPayload, error) {
version, err := s.deps.Store.GetHostScheduleVersion(ctx, hostID)
if err != nil {
return api.ScheduleSetPayload{}, err
}
schedules, err := s.deps.Store.ListSchedulesByHost(ctx, hostID)
if err != nil {
return api.ScheduleSetPayload{}, err
}
groups, err := s.deps.Store.ListSourceGroupsByHost(ctx, hostID)
if err != nil {
return api.ScheduleSetPayload{}, err
}
groupByID := make(map[string]store.SourceGroup, len(groups))
for _, g := range groups {
groupByID[g.ID] = g
}
out := api.ScheduleSetPayload{Version: version, Schedules: make([]api.Schedule, 0, len(schedules))}
for _, sc := range schedules {
entry := api.Schedule{
ID: sc.ID,
CronExpr: sc.CronExpr,
Enabled: sc.Enabled,
SourceGroups: make([]api.ScheduleSourceGroup, 0, len(sc.SourceGroupIDs)),
}
for _, gid := range sc.SourceGroupIDs {
g, ok := groupByID[gid]
if !ok {
continue
}
retention, _ := json.Marshal(g.RetentionPolicy)
entry.SourceGroups = append(entry.SourceGroups, api.ScheduleSourceGroup{
Name: g.Name,
Includes: g.Includes,
Excludes: g.Excludes,
RetentionPolicy: retention,
RetryMax: g.RetryMax,
RetryBackoffSeconds: g.RetryBackoffSeconds,
})
}
out.Schedules = append(out.Schedules, entry)
}
return out, nil
}
// applyScheduleAck persists the version the agent has confirmed.
func (s *Server) applyScheduleAck(ctx context.Context, hostID string, version int64, appliedAt time.Time) {
if err := s.deps.Store.SetHostAppliedScheduleVersion(ctx, hostID, version); err != nil {
slog.Warn("schedule.ack: persist applied version", "host_id", hostID, "err", err)
}
}
// dispatchScheduledJob handles an agent's schedule.fire. Resolves the
// schedule's source groups and dispatches one backup command.run per
// group, persisting each as a job row with actor_kind=schedule and
// scheduled_id pointing at the schedule.
func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn *ws.Conn, scheduleID string, scheduledAt time.Time) {
slog.Info("schedule.fire: stubbed during P2 redesign",
"host_id", hostID, "schedule_id", scheduleID, "scheduled_at", scheduledAt)
sc, err := s.deps.Store.GetSchedule(ctx, hostID, scheduleID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
slog.Info("schedule.fire: schedule unknown, ignoring",
"host_id", hostID, "schedule_id", scheduleID)
return
}
slog.Warn("schedule.fire: load schedule", "host_id", hostID, "err", err)
return
}
if !sc.Enabled {
slog.Info("schedule.fire: schedule disabled, ignoring",
"host_id", hostID, "schedule_id", scheduleID)
return
}
if len(sc.SourceGroupIDs) == 0 {
slog.Warn("schedule.fire: schedule has no source groups",
"host_id", hostID, "schedule_id", scheduleID)
return
}
for _, gid := range sc.SourceGroupIDs {
g, err := s.deps.Store.GetSourceGroup(ctx, hostID, gid)
if err != nil {
slog.Warn("schedule.fire: load source group",
"host_id", hostID, "schedule_id", scheduleID, "group_id", gid, "err", err)
continue
}
s.dispatchBackupForGroup(ctx, conn, hostID, scheduleID, g, scheduledAt)
}
}
// dispatchBackupForGroup builds and sends a single backup command.run
// envelope on conn for the given group. Persists the job row first so
// the live log viewer can subscribe to it.
func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, hostID, scheduleID string, g *store.SourceGroup, scheduledAt time.Time) {
jobID := ulid.Make().String()
now := time.Now().UTC()
scheduleRef := scheduleID
if err := s.deps.Store.CreateJob(ctx, store.Job{
ID: jobID,
HostID: hostID,
Kind: string(api.JobBackup),
ScheduledID: &scheduleRef,
ActorKind: "schedule",
CreatedAt: now,
}); err != nil {
slog.Warn("schedule.fire: persist job", "host_id", hostID,
"schedule_id", scheduleID, "group", g.Name, "err", err)
return
}
retention, _ := json.Marshal(g.RetentionPolicy)
env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{
JobID: jobID,
Kind: api.JobBackup,
Includes: g.Includes,
Excludes: g.Excludes,
Tag: g.Name,
RetentionPolicy: retention,
})
if err != nil {
slog.Warn("schedule.fire: marshal command.run",
"host_id", hostID, "schedule_id", scheduleID, "err", err)
return
}
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := conn.Send(sendCtx, env); err != nil {
slog.Warn("schedule.fire: send command.run",
"host_id", hostID, "schedule_id", scheduleID, "err", err)
return
}
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
ID: ulid.Make().String(),
Actor: "schedule",
Action: "job.run_now",
TargetKind: ptr("job"),
TargetID: &jobID,
TS: now,
})
slog.Info("schedule.fire: dispatched backup",
"host_id", hostID, "schedule_id", scheduleID,
"group", g.Name, "job_id", jobID, "scheduled_at", scheduledAt)
}
+201 -19
View File
@@ -1,37 +1,219 @@
// schedules.go — REST API for /api/hosts/{id}/schedules.
//
// Slim-shape body: {cron, enabled, source_group_ids[]}. Paths,
// excludes, retention, retry, kind, manual — all gone. Those live on
// SourceGroup; a schedule is just "fire this cron, run backups for
// these groups." Mutations bump host_schedule_version and (best-effort)
// push the new set to a connected agent.
package http
import (
"encoding/json"
"errors"
stdhttp "net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"github.com/robfig/cron/v3"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// schedules.go — REST API for /api/hosts/{id}/schedules.
//
// Stubbed during the P2 redesign data-model rewrite (commit chain).
// Phase 2 dropped the fat Schedule shape (paths/excludes/tags/
// retention/manual/kind/options/hooks) — the slim Schedule + source
// groups model lives in store/. Phase 3 of the redesign will fill in
// these handlers against the new shape.
//
// Returning 501 here keeps the routes addressable; UI calls will
// surface the unimplemented state via the toast component until the
// new handlers land.
// scheduleView is the JSON shape returned by GET. Stable wire format
// — UI form binds to it.
type scheduleView struct {
ID string `json:"id"`
HostID string `json:"host_id"`
CronExpr string `json:"cron"`
Enabled bool `json:"enabled"`
SourceGroupIDs []string `json:"source_group_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toScheduleView(s store.Schedule) scheduleView {
ids := s.SourceGroupIDs
if ids == nil {
ids = []string{}
}
return scheduleView{
ID: s.ID, HostID: s.HostID,
CronExpr: s.CronExpr, Enabled: s.Enabled,
SourceGroupIDs: ids,
CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt,
}
}
// scheduleWriteRequest is the body of POST and PUT.
type scheduleWriteRequest struct {
CronExpr string `json:"cron"`
Enabled bool `json:"enabled"`
SourceGroupIDs []string `json:"source_group_ids"`
}
// cronParser mirrors robfig/cron/v3's New() default; reuse it across
// every validate call so we're consistent with what the agent uses.
var cronParser = cron.NewParser(
cron.SecondOptional | cron.Minute | cron.Hour |
cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
)
func (s *Server) handleListSchedules(w stdhttp.ResponseWriter, r *stdhttp.Request) {
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
if hostID == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "")
return
}
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
return
}
rows, err := s.deps.Store.ListSchedulesByHost(r.Context(), hostID)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
out := make([]scheduleView, 0, len(rows))
for _, sc := range rows {
out = append(out, toScheduleView(sc))
}
writeJSON(w, stdhttp.StatusOK, struct {
Schedules []scheduleView `json:"schedules"`
}{Schedules: out})
}
func (s *Server) handleCreateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) {
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
if hostID == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "")
return
}
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
return
}
var req scheduleWriteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
if code, msg, ok := s.validateScheduleRequest(r, hostID, req); !ok {
writeJSONError(w, stdhttp.StatusBadRequest, code, msg)
return
}
sc := store.Schedule{
ID: ulid.Make().String(), HostID: hostID,
CronExpr: req.CronExpr, Enabled: req.Enabled,
SourceGroupIDs: req.SourceGroupIDs,
}
if err := s.deps.Store.CreateSchedule(r.Context(), &sc); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
s.pushScheduleSetAsync(hostID)
writeJSON(w, stdhttp.StatusCreated, toScheduleView(sc))
}
func (s *Server) handleUpdateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) {
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
scheduleID := chi.URLParam(r, "sid")
if hostID == "" || scheduleID == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "")
return
}
if _, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusNotFound, "schedule_not_found", "")
return
}
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
var req scheduleWriteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
if code, msg, ok := s.validateScheduleRequest(r, hostID, req); !ok {
writeJSONError(w, stdhttp.StatusBadRequest, code, msg)
return
}
sc := store.Schedule{
ID: scheduleID, HostID: hostID,
CronExpr: req.CronExpr, Enabled: req.Enabled,
SourceGroupIDs: req.SourceGroupIDs,
}
if err := s.deps.Store.UpdateSchedule(r.Context(), &sc); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
s.pushScheduleSetAsync(hostID)
out, _ := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID)
if out != nil {
writeJSON(w, stdhttp.StatusOK, toScheduleView(*out))
return
}
writeJSON(w, stdhttp.StatusOK, toScheduleView(sc))
}
func (s *Server) handleDeleteSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) {
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
scheduleID := chi.URLParam(r, "sid")
if hostID == "" || scheduleID == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "")
return
}
if err := s.deps.Store.DeleteSchedule(r.Context(), hostID, scheduleID); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusNotFound, "schedule_not_found", "")
return
}
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
s.pushScheduleSetAsync(hostID)
w.WriteHeader(stdhttp.StatusNoContent)
}
// validateScheduleRequest enforces wire-shape rules: cron must parse,
// at least one source group must be attached, and every referenced
// group must belong to this host. Returns (code, msg, ok=false) on
// failure; ok=true means proceed.
func (s *Server) validateScheduleRequest(r *stdhttp.Request, hostID string, req scheduleWriteRequest) (string, string, bool) {
if req.CronExpr == "" {
return "missing_field", "cron is required", false
}
if _, err := cronParser.Parse(req.CronExpr); err != nil {
return "invalid_cron", err.Error(), false
}
if len(req.SourceGroupIDs) == 0 {
return "missing_field", "source_group_ids must contain at least one group", false
}
// Every referenced group must exist and belong to this host.
for _, gid := range req.SourceGroupIDs {
g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, gid)
if err != nil || g == nil {
return "invalid_group", "source group "+gid+" not found on this host", false
}
}
return "", "", true
}
+34 -8
View File
@@ -105,15 +105,43 @@ func (s *Server) routes(r chi.Router) {
r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
// Per-host schedule CRUD. Mutations bump host_schedule_version;
// the agent sync path (P2-02) picks up the new version on the
// next reconciliation tick.
// Per-host schedule CRUD. Mutations bump host_schedule_version
// and async-push to a connected agent (see schedule_push.go).
r.Get("/hosts/{id}/schedules", s.handleListSchedules)
r.Post("/hosts/{id}/schedules", s.handleCreateSchedule)
r.Put("/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule)
r.Delete("/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule)
// Source-group CRUD. A group is "what gets backed up" — paths,
// excludes, retention, retry. Group name doubles as the
// snapshot tag (restic --tag <name>).
r.Get("/hosts/{id}/source-groups", s.handleListSourceGroups)
r.Post("/hosts/{id}/source-groups", s.handleCreateSourceGroup)
r.Get("/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup)
r.Put("/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup)
r.Delete("/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup)
// Repo maintenance cadences (forget / prune / check). Driven
// by the server-side ticker (P2R-06), not the agent's cron.
r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance)
// Per-source-group Run-now (JSON variant). HTMX action is
// mounted at the equivalent path outside /api below — both
// resolve to the same handler, which sniffs HX-Request.
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
})
// Per-source-group Run-now (HTMX form action). Available even
// when the server is started without UI templates so REST callers
// against the non-/api path also work.
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
// Retired routes — see ui_handlers.go for the messages. Mounted
// outside the UI gate so cached browser tabs get a clear 410
// even if the server runs without templates.
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone)
r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone)
// Agent ↔ server WebSocket. Bearer-authenticated inside the handler.
if s.deps.Hub != nil {
r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{
@@ -143,11 +171,9 @@ func (s *Server) routes(r chi.Router) {
r.Get("/login", s.handleUILoginGet)
r.Post("/login", s.handleUILoginPost)
r.Post("/logout", s.handleUILogoutPost)
// HTMX action endpoint for "Run now" buttons on the dashboard.
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackup)
// HTMX action endpoint for the red "Initialise repo" button
// shown in the run-now panel until the repo is confirmed init'd.
r.Post("/hosts/{id}/init-repo", s.handleUIInitRepo)
// Per-host Run-now and manual Init-repo are mounted at the
// outer router (so they reply 410 even without UI). Per-
// source-group Run-now lives there too — same reason.
// Add host flow.
r.Get("/hosts/new", s.handleUIAddHostGet)
r.Post("/hosts/new", s.handleUIAddHostPost)
+242
View File
@@ -0,0 +1,242 @@
// source_groups.go — REST API for /api/hosts/{id}/source-groups.
//
// A source group is "what gets backed up": a named bundle of include
// + exclude paths, a retention policy, and retry knobs. Group name
// doubles as the snapshot tag (restic --tag <name>).
package http
import (
"encoding/json"
"errors"
stdhttp "net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// sourceGroupView is the JSON shape returned by GET endpoints.
type sourceGroupView struct {
ID string `json:"id"`
HostID string `json:"host_id"`
Name string `json:"name"`
Includes []string `json:"includes"`
Excludes []string `json:"excludes"`
RetentionPolicy store.RetentionPolicy `json:"retention_policy"`
RetryMax int `json:"retry_max"`
RetryBackoffSeconds int `json:"retry_backoff_seconds"`
ConflictDimension string `json:"conflict_dimension,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toSourceGroupView(g store.SourceGroup) sourceGroupView {
includes := g.Includes
if includes == nil {
includes = []string{}
}
excludes := g.Excludes
if excludes == nil {
excludes = []string{}
}
return sourceGroupView{
ID: g.ID, HostID: g.HostID, Name: g.Name,
Includes: includes, Excludes: excludes,
RetentionPolicy: g.RetentionPolicy,
RetryMax: g.RetryMax,
RetryBackoffSeconds: g.RetryBackoffSeconds,
ConflictDimension: g.ConflictDimension,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
}
}
// sourceGroupWriteRequest is the body of POST and PUT.
type sourceGroupWriteRequest struct {
Name string `json:"name"`
Includes []string `json:"includes"`
Excludes []string `json:"excludes"`
RetentionPolicy store.RetentionPolicy `json:"retention_policy"`
RetryMax int `json:"retry_max"`
RetryBackoffSeconds int `json:"retry_backoff_seconds"`
}
func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
return
}
rows, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), hostID)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
out := make([]sourceGroupView, 0, len(rows))
for _, g := range rows {
out = append(out, toSourceGroupView(g))
}
writeJSON(w, stdhttp.StatusOK, struct {
SourceGroups []sourceGroupView `json:"source_groups"`
}{SourceGroups: out})
}
func (s *Server) handleGetSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
groupID := chi.URLParam(r, "gid")
g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "")
return
}
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
writeJSON(w, stdhttp.StatusOK, toSourceGroupView(*g))
}
func (s *Server) handleCreateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
return
}
var req sourceGroupWriteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "name required")
return
}
// Name must be unique per host (the store has a UNIQUE constraint
// but pre-check gives a friendlier error than a 500).
if existing, err := s.deps.Store.GetSourceGroupByName(r.Context(), hostID, req.Name); err == nil && existing != nil {
writeJSONError(w, stdhttp.StatusConflict, "name_taken",
"a source group named "+req.Name+" already exists on this host")
return
}
g := store.SourceGroup{
ID: ulid.Make().String(), HostID: hostID, Name: req.Name,
Includes: req.Includes, Excludes: req.Excludes,
RetentionPolicy: req.RetentionPolicy,
RetryMax: req.RetryMax,
RetryBackoffSeconds: req.RetryBackoffSeconds,
}
if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
s.pushScheduleSetAsync(hostID)
writeJSON(w, stdhttp.StatusCreated, toSourceGroupView(g))
}
func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
groupID := chi.URLParam(r, "gid")
if _, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "")
return
}
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
var req sourceGroupWriteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "name required")
return
}
// If renaming, ensure the new name doesn't collide with another group.
if existing, err := s.deps.Store.GetSourceGroupByName(r.Context(), hostID, req.Name); err == nil && existing != nil && existing.ID != groupID {
writeJSONError(w, stdhttp.StatusConflict, "name_taken",
"a source group named "+req.Name+" already exists on this host")
return
}
g := store.SourceGroup{
ID: groupID, HostID: hostID, Name: req.Name,
Includes: req.Includes, Excludes: req.Excludes,
RetentionPolicy: req.RetentionPolicy,
RetryMax: req.RetryMax,
RetryBackoffSeconds: req.RetryBackoffSeconds,
}
if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
s.pushScheduleSetAsync(hostID)
out, _ := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID)
if out != nil {
writeJSON(w, stdhttp.StatusOK, toSourceGroupView(*out))
return
}
writeJSON(w, stdhttp.StatusOK, toSourceGroupView(g))
}
// handleDeleteSourceGroup refuses to delete a group that is still
// referenced by any schedule. Returns 409 with the schedule list so
// the UI can offer "remove from these schedules first."
func (s *Server) handleDeleteSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
groupID := chi.URLParam(r, "gid")
using, err := s.deps.Store.SchedulesUsingGroup(r.Context(), groupID)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
if len(using) > 0 {
writeJSON(w, stdhttp.StatusConflict, struct {
Code string `json:"code"`
Message string `json:"message"`
Schedules []string `json:"schedules"`
}{
Code: "group_in_use",
Message: "remove this group from the listed schedules before deleting",
Schedules: using,
})
return
}
if err := s.deps.Store.DeleteSourceGroup(r.Context(), hostID, groupID); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "")
return
}
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
s.pushScheduleSetAsync(hostID)
w.WriteHeader(stdhttp.StatusNoContent)
}
+10 -12
View File
@@ -142,23 +142,21 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
}
}
// handleUIRunBackup and handleUIInitRepo are stubbed during the P2
// redesign data-model rewrite. The dashboard per-host Run-now button
// is going away (operator clicks into host detail then a per-source-
// group Run-now), and Init-repo becomes implicit at host enrolment
// (auto-init dispatched server-side). Phase 4 of the redesign wires
// the new per-source-group Run-now via /hosts/{id}/source-groups/{gid}/run.
// Per-host Run-now and manual Init-repo were retired by the P2 redesign.
// Run-now lives at POST /hosts/{id}/source-groups/{gid}/run; init runs
// automatically on the agent's first WS connect after enrolment. Both
// routes return 410 Gone so any cached browser tab gets a clear error.
func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
func (s *Server) handleUIRunBackupGone(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w,
"per-host Run-now is being replaced by per-source-group Run-now — see P2 redesign Phase 4",
stdhttp.StatusNotImplemented)
"per-host Run-now has moved — use POST /hosts/{id}/source-groups/{gid}/run",
stdhttp.StatusGone)
}
func (s *Server) handleUIInitRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) {
func (s *Server) handleUIInitRepoGone(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w,
"manual Init-repo is being replaced by auto-init at host enrolment — see P2 redesign Phase 6",
stdhttp.StatusNotImplemented)
"manual init-repo is gone — the server auto-inits on the agent's first connect",
stdhttp.StatusGone)
}
// addHostPage carries the Add-host form state. The result-state
+18
View File
@@ -193,6 +193,24 @@ func (s *Store) GetJob(ctx context.Context, id string) (*Job, error) {
return &j, nil
}
// HasJobOfKind reports whether any job of the given kind exists for
// this host, regardless of status. Used by the auto-init path on
// agent hello to decide whether to dispatch a fresh `restic init` —
// once we've tried once we don't auto-retry, even on failure
// (failed init usually means bad creds; retrying every reconnect
// just piles up failed rows). The operator can re-init manually via
// the Repo page's danger zone.
func (s *Store) HasJobOfKind(ctx context.Context, hostID, kind string) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = ?`,
hostID, kind).Scan(&n)
if err != nil {
return false, fmt.Errorf("store: count jobs of kind: %w", err)
}
return n > 0, nil
}
func nullableStr(s string) any {
if s == "" {
return nil
+2 -2
View File
@@ -131,9 +131,9 @@ Sizes: **S** = under a day, **M** = 13 days, **L** = 37 days.
- [x] **P2R-00.3** (L) Go-side store rewrite against migration 0008. New types: `SourceGroup`, `HostRepoMaintenance`, `PendingRun`. `Schedule` slimmed to `{id, host_id, cron, enabled, source_group_ids, timestamps}`. `RetentionPolicy` moves from schedule field → source group field (type unchanged). `Host` loses `RepoInitialisedAt`, gains bandwidth caps. New files: `store/sources.go`, `store/maintenance.go`, `store/pending.go`. `store/schedules.go` rewritten for slim shape + junction CRUD. `enrollment.go` seeds a default source group + repo-maintenance row instead of a manual schedule. `ws/handler.go` drops `MarkHostRepoInitialised`. HTTP layer + UI templates **temporarily 501-stubbed** with `redesign_in_progress` — this is what P2R-01 / P2R-02 fill back in. Tests for the obsolete fat-schedule API deleted. Commit `5667cdf`.
- [x] **P2R-00.4** (S) Host-detail UI patched up enough to render: `RepoInitialisedAt` template refs removed, manual init-repo branches stripped, dead Schedules sub-tab demoted to inert div (matches Jobs/Repo/Settings), broken Run-now buttons disabled with P2-Phase-4 hints. Stop-gap until P2R-02 lands the real surface.
### P2 redesign — Phase 3 (REST + WS rewire) — TODO
### P2 redesign — Phase 3 (REST + WS rewire)
- [ ] **P2R-01** (L) HTTP/WS layer against the slim shape:
- [x] **P2R-01** (L) HTTP/WS layer against the slim shape:
- **Schedules REST CRUD**: `GET|POST /api/hosts/{id}/schedules`, `PUT|DELETE /api/hosts/{id}/schedules/{sid}`. Body shape is `{cron, enabled, source_group_ids[]}` — paths/excludes/retention/kind/manual all go away. Junction wiped + re-inserted on every update (per `store.UpdateSchedule`). Validation: cron parses via `robfig/cron/v3`; ≥1 `source_group_ids`; all referenced groups belong to the host.
- **Source-groups REST CRUD**: `GET|POST /api/hosts/{id}/source-groups`, `GET|PUT|DELETE /api/hosts/{id}/source-groups/{gid}`. Body: `{name, includes[], excludes[], retention_policy, retry_max, retry_backoff_seconds}`. Name uniqueness per host. Refuse delete if `SchedulesUsingGroup(gid)` is non-empty (return the schedule list so UI can show "remove from these schedules first"). Mutations bump `host_schedule_version`.
- **Repo-maintenance REST**: `GET|PUT /api/hosts/{id}/repo-maintenance`. Body: `{forget_cadence, prune_cadence, check_cadence, check_subset_pct, enabled}`. Server-side ticker drives execution (P2R-04), so updates here do **not** bump `host_schedule_version`.
File diff suppressed because one or more lines are too long