Fix source-group API, backup validation, and orphaned jobs #57

Merged
steve merged 3 commits from fix-issues-54-55-56 into main 2026-08-22 13:45:04 +01:00
3 changed files with 38 additions and 5 deletions
Showing only changes of commit 4f56b8f705 - Show all commits
@@ -6,11 +6,12 @@ Three ways to trigger one:
1. **Scheduled** — the agent's local cron fires at the time set 1. **Scheduled** — the agent's local cron fires at the time set
on the schedule. on the schedule.
2. **Run-now** — operator clicks **Run now** on the host detail 2. **Run-now** — operator clicks **Run now** for a specific source
right rail. Posts to `/hosts/{id}/run-backup` (defaults to all group. This uses `POST /hosts/{id}/source-groups/{gid}/run`.
source groups) or to a per-group form for finer control. 3. **API** — use `POST /api/hosts/{id}/source-groups/{gid}/run` for a
3. **API** `POST /api/hosts/{id}/jobs` with the appropriate configured source group. The lower-level `POST /api/hosts/{id}/jobs`
payload. Same audit + dispatch path. 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 In every case the server creates a `jobs` row, broadcasts a
`command.run` to the host, and lands the operator on the live `command.run` to the host, and lands the operator on the live
+4
View File
@@ -69,6 +69,10 @@ func (s *Server) dispatchJob(ctx context.Context, user *store.User,
Kind: kind, Kind: kind,
Args: args, 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 kind == api.JobForget {
if !validForgetArgs(args) { if !validForgetArgs(args) {
return res, stdhttp.StatusBadRequest, "invalid_args", return res, stdhttp.StatusBadRequest, "invalid_args",
+28
View File
@@ -14,6 +14,34 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/store" "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) { func TestRunNowForgetShipsRetentionGroupsAndDryRun(t *testing.T) {
t.Parallel() t.Parallel()
srv, ts, st := rawTestServer(t) srv, ts, st := rawTestServer(t)