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 0735038ea8
commit ec0bf0f6c3
18 changed files with 1564 additions and 101 deletions
+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