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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -131,9 +131,9 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 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
Reference in New Issue
Block a user