From 4f56b8f705e0baadc9d91a3d7882bb287c6f2d65 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sat, 22 Aug 2026 13:42:42 +0100 Subject: [PATCH] Reject backup jobs without paths --- .../src/operations/backups-and-restores.md | 11 ++++---- internal/server/http/jobs.go | 4 +++ internal/server/http/jobs_test.go | 28 +++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/book/src/operations/backups-and-restores.md b/docs/book/src/operations/backups-and-restores.md index 31c1a29..f97ad91 100644 --- a/docs/book/src/operations/backups-and-restores.md +++ b/docs/book/src/operations/backups-and-restores.md @@ -6,11 +6,12 @@ Three ways to trigger one: 1. **Scheduled** — the agent's local cron fires at the time set on the schedule. -2. **Run-now** — operator clicks **Run now** on the host detail - right rail. Posts to `/hosts/{id}/run-backup` (defaults to all - source groups) or to a per-group form for finer control. -3. **API** — `POST /api/hosts/{id}/jobs` with the appropriate - payload. Same audit + dispatch path. +2. **Run-now** — operator clicks **Run now** for a specific source + group. This uses `POST /hosts/{id}/source-groups/{gid}/run`. +3. **API** — use `POST /api/hosts/{id}/source-groups/{gid}/run` for a + configured source group. The lower-level `POST /api/hosts/{id}/jobs` + backup form requires explicit paths in `args`; an empty backup is + rejected instead of dispatching a job that restic cannot run. In every case the server creates a `jobs` row, broadcasts a `command.run` to the host, and lands the operator on the live diff --git a/internal/server/http/jobs.go b/internal/server/http/jobs.go index a71c679..c3e97b9 100644 --- a/internal/server/http/jobs.go +++ b/internal/server/http/jobs.go @@ -69,6 +69,10 @@ func (s *Server) dispatchJob(ctx context.Context, user *store.User, Kind: kind, Args: args, } + if kind == api.JobBackup && len(args) == 0 { + return res, stdhttp.StatusUnprocessableEntity, "backup_paths_required", + "backup requires paths in args; configured backups must use POST /api/hosts/{id}/source-groups/{gid}/run" + } if kind == api.JobForget { if !validForgetArgs(args) { return res, stdhttp.StatusBadRequest, "invalid_args", diff --git a/internal/server/http/jobs_test.go b/internal/server/http/jobs_test.go index b526591..e53572f 100644 --- a/internal/server/http/jobs_test.go +++ b/internal/server/http/jobs_test.go @@ -14,6 +14,34 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) +func TestRunNowBackupRejectsEmptyPaths(t *testing.T) { + t.Parallel() + _, ts, st := rawTestServer(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "empty-backup-host") + req, _ := stdhttp.NewRequest(stdhttp.MethodPost, ts.URL+"/api/hosts/"+hostID+"/jobs", + bytes.NewReader([]byte(`{"kind":"backup"}`))) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post run-now: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusUnprocessableEntity { + t.Fatalf("status: got %d, want 422", res.StatusCode) + } + body := readJSONError(t, res.Body) + if body.Code != "backup_paths_required" { + t.Fatalf("code: got %q", body.Code) + } + var jobs int + _ = st.DB().QueryRow(`SELECT COUNT(*) FROM jobs WHERE host_id = ?`, hostID).Scan(&jobs) + if jobs != 0 { + t.Fatalf("invalid backup created %d jobs", jobs) + } +} + func TestRunNowForgetShipsRetentionGroupsAndDryRun(t *testing.T) { t.Parallel() srv, ts, st := rawTestServer(t)