From 69abc40786a5576eb3b70caefae9ebbd8dcf9190 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 22:57:07 +0100 Subject: [PATCH] server: HTTP run-now for prune / check / unlock Adds POST /api/hosts/{id}/repo/{prune,check,unlock} (and matching outer routes for HTMX form posts). Prune pushes the admin-cred slot via pushAdminCredsToAgent before dispatch and refuses with admin_creds_required when the slot is not set. Check reads check_subset_pct from host_repo_maintenance (overridable via ?subset=N, clamped 0-100; non-numeric override falls back to DB value silently). Unlock needs no admin creds. All three share the same wantsHTML/HX-Redirect response split as the per-source-group run-now endpoint. --- internal/server/http/repo_ops.go | 163 +++++++++++++ internal/server/http/repo_ops_test.go | 331 ++++++++++++++++++++++++++ internal/server/http/server.go | 11 + 3 files changed, 505 insertions(+) create mode 100644 internal/server/http/repo_ops.go create mode 100644 internal/server/http/repo_ops_test.go diff --git a/internal/server/http/repo_ops.go b/internal/server/http/repo_ops.go new file mode 100644 index 0000000..14c3812 --- /dev/null +++ b/internal/server/http/repo_ops.go @@ -0,0 +1,163 @@ +// repo_ops.go — operator-triggered Run-now for repo-level operations: +// prune, check, unlock. Backed by the same dispatchJobWithPayload +// pipeline as backup, with an extra step for prune: push admin creds +// first if they're set, refuse loudly if they aren't. +package http + +import ( + "errors" + stdhttp "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// handleRunRepoPrune — POST /api/hosts/{id}/repo/prune (and the HTMX +// twin outside /api). Pushes the host's admin credentials down the WS, +// then dispatches a prune command.run with RequiresAdminCreds=true. +func (s *Server) handleRunRepoPrune(w stdhttp.ResponseWriter, r *stdhttp.Request) { + user, ok := s.requireUser(r) + if !ok { + if wantsHTML(r) { + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + return + } + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if hostID == "" { + s.runOpError(w, r, stdhttp.StatusBadRequest, "missing_id", "") + return + } + + // Push admin creds first. ErrNotFound → operator hasn't set them + // yet. Other errors → likely the host is offline or a decrypt fail. + if err := s.pushAdminCredsToAgent(r.Context(), hostID); err != nil { + if errors.Is(err, store.ErrNotFound) { + s.runOpError(w, r, stdhttp.StatusBadRequest, "admin_creds_required", + "set admin credentials on the Repo page before running prune") + return + } + // Hub.Send failure (offline) or decrypt failure — surface a + // generic offline message so the operator retries when the + // agent is back. + s.runOpError(w, r, stdhttp.StatusServiceUnavailable, "host_offline", + "agent is not currently connected; try again when it reconnects") + return + } + + res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobPrune, + api.CommandRunPayload{RequiresAdminCreds: true}) + if code != "" { + s.runOpError(w, r, status, code, msg) + return + } + s.runOpRedirect(w, r, res) +} + +// handleRunRepoCheck — POST /api/hosts/{id}/repo/check. Pulls +// check_subset_pct from host_repo_maintenance for the host (operator +// can override via ?subset=N query param, clamped 0..100). Dispatches +// with the chosen subset in CommandRunPayload.Args[0]. +func (s *Server) handleRunRepoCheck(w stdhttp.ResponseWriter, r *stdhttp.Request) { + user, ok := s.requireUser(r) + if !ok { + if wantsHTML(r) { + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + return + } + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if hostID == "" { + s.runOpError(w, r, stdhttp.StatusBadRequest, "missing_id", "") + return + } + + m, err := s.deps.Store.GetRepoMaintenance(r.Context(), hostID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + // Maintenance row should auto-seed at enrollment. If it's + // missing, surface a clear error rather than guessing 0%. + s.runOpError(w, r, stdhttp.StatusInternalServerError, "no_maintenance_row", + "host has no repo-maintenance config; was the host fully enrolled?") + return + } + s.runOpError(w, r, stdhttp.StatusInternalServerError, "internal", "") + return + } + subset := m.CheckSubsetPct + if q := r.URL.Query().Get("subset"); q != "" { + if n, err2 := strconv.Atoi(q); err2 == nil { + if n < 0 { + n = 0 + } + if n > 100 { + n = 100 + } + subset = n + } + // Non-numeric ?subset silently falls back to DB value. + } + + res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobCheck, + api.CommandRunPayload{Args: []string{strconv.Itoa(subset)}}) + if code != "" { + s.runOpError(w, r, status, code, msg) + return + } + s.runOpRedirect(w, r, res) +} + +// handleRunRepoUnlock — POST /api/hosts/{id}/repo/unlock. No admin +// creds required — restic unlock works with the everyday user. +func (s *Server) handleRunRepoUnlock(w stdhttp.ResponseWriter, r *stdhttp.Request) { + user, ok := s.requireUser(r) + if !ok { + if wantsHTML(r) { + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + return + } + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if hostID == "" { + s.runOpError(w, r, stdhttp.StatusBadRequest, "missing_id", "") + return + } + + res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobUnlock, + api.CommandRunPayload{}) + if code != "" { + s.runOpError(w, r, status, code, msg) + return + } + s.runOpRedirect(w, r, res) +} + +// runOpRedirect: HTMX → HX-Redirect to /jobs/{id}; JSON → 202 + JSON +// body. Mirrors handleRunSourceGroup's tail. +func (s *Server) runOpRedirect(w stdhttp.ResponseWriter, r *stdhttp.Request, res runNowResponse) { + if wantsHTML(r) { + w.Header().Set("HX-Redirect", "/jobs/"+res.JobID) + w.WriteHeader(stdhttp.StatusNoContent) + return + } + writeJSON(w, stdhttp.StatusAccepted, res) +} + +// runOpError: HTMX → plain-text status; JSON → standard envelope. +// Mirrors runGroupError. +func (s *Server) runOpError(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) +} diff --git a/internal/server/http/repo_ops_test.go b/internal/server/http/repo_ops_test.go new file mode 100644 index 0000000..a3b7a88 --- /dev/null +++ b/internal/server/http/repo_ops_test.go @@ -0,0 +1,331 @@ +// repo_ops_test.go — integration tests for the repo run-now endpoints: +// prune, check, unlock. +package http + +import ( + "context" + "encoding/json" + stdhttp "net/http" + "strconv" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// ----- helpers ------------------------------------------------------- + +// seedInitJob marks a fake init job done for the host so the auto-init +// path doesn't fire and pollute the envelope sequence we're measuring. +func seedInitJob(t *testing.T, st *store.Store, hostID string) { + t.Helper() + if err := st.CreateJob(context.Background(), store.Job{ + ID: ulid.Make().String(), HostID: hostID, Kind: "init", + ActorKind: "system", CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("seed init job: %v", err) + } +} + +// setAdminCreds writes admin credentials for a host via the store directly. +func setAdminCreds(t *testing.T, srv *Server, st *store.Store, hostID string) { + t.Helper() + enc, err := srv.encryptRepoCreds(repoCredsBlob{ + RepoURL: "rest:http://admin.example/h", + RepoUsername: "admin", + RepoPassword: "prune-pass", + }, []byte("host:"+hostID+":admin")) + if err != nil { + t.Fatalf("encrypt admin creds: %v", err) + } + if err := st.SetHostCredentials(context.Background(), hostID, store.CredKindAdmin, enc); err != nil { + t.Fatalf("set admin creds: %v", err) + } +} + +// setMaintenanceSubset sets check_subset_pct for the host via the store. +func setMaintenanceSubset(t *testing.T, st *store.Store, hostID string, pct int) { + t.Helper() + // Ensure the row exists first. + if err := st.CreateDefaultRepoMaintenance(context.Background(), hostID); err != nil { + t.Fatalf("seed maintenance: %v", err) + } + m, err := st.GetRepoMaintenance(context.Background(), hostID) + if err != nil { + t.Fatalf("get maintenance: %v", err) + } + m.CheckSubsetPct = pct + if err := st.UpdateRepoMaintenance(context.Background(), m); err != nil { + t.Fatalf("update maintenance: %v", err) + } +} + +// drainCommandRun reads envelopes until a command.run arrives, then +// unmarshals and returns the payload. +func drainCommandRun(t *testing.T, c *websocket.Conn) api.CommandRunPayload { + t.Helper() + env := drainUntil(t, c, api.MsgCommandRun) + var p api.CommandRunPayload + if err := env.UnmarshalPayload(&p); err != nil { + t.Fatalf("unmarshal command.run: %v", err) + } + return p +} + +// ----- prune tests --------------------------------------------------- + +// TestRunPruneRefusesWithoutAdminCreds: POST prune with no admin creds +// set → 400, code admin_creds_required, no job row created. +func TestRunPruneRefusesWithoutAdminCreds(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + hostID, token := enrolHostForWS(t, srv, st, "prune-no-admin") + cookie := loginAsAdmin(t, st) + seedInitJob(t, st, hostID) + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "prune-no-admin") + _ = drainUntil(t, c, api.MsgScheduleSet) + + status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/prune", nil, cookie) + if status != stdhttp.StatusBadRequest { + t.Fatalf("want 400, got %d body=%+v", status, body) + } + if code, _ := body["code"].(string); code != "admin_creds_required" { + t.Errorf("want code=admin_creds_required, got %+v", body) + } + + // No prune job row should have been persisted. + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'prune'`, hostID).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Errorf("unexpected prune job rows: %d", n) + } +} + +// TestRunPruneShipsConfigUpdateThenCommandRun: set admin creds, connect +// host, POST prune. Assert envelope sequence: config.update(slot=admin) +// → command.run(prune, RequiresAdminCreds=true). Assert job row persisted. +func TestRunPruneShipsConfigUpdateThenCommandRun(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + hostID, token := enrolHostForWS(t, srv, st, "prune-happy") + cookie := loginAsAdmin(t, st) + setAdminCreds(t, srv, st, hostID) + seedInitJob(t, st, hostID) + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "prune-happy") + // Drain on-hello burst (repo config.update + schedule.set). + _ = drainUntil(t, c, api.MsgScheduleSet) + + status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/prune", nil, cookie) + if status != stdhttp.StatusAccepted { + t.Fatalf("want 202, got %d body=%+v", status, body) + } + jobID, _ := body["job_id"].(string) + if jobID == "" { + t.Fatalf("no job_id in response: %+v", body) + } + + // Read the next two envelopes — must be config.update(slot=admin) + // followed by command.run(prune). + deadline := time.Now().Add(3 * time.Second) + var sawAdminPush bool + var prunePayload *api.CommandRunPayload + for (prunePayload == nil) && time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond) + mt, raw, err := c.Read(ctx) + cancel() + if err != nil { + break + } + if mt != websocket.MessageText { + continue + } + var env api.Envelope + if err := json.Unmarshal(raw, &env); err != nil { + continue + } + switch env.Type { + case api.MsgConfigUpdate: + var p api.ConfigUpdatePayload + if err := env.UnmarshalPayload(&p); err == nil && p.Slot == "admin" { + sawAdminPush = true + } + case api.MsgCommandRun: + var p api.CommandRunPayload + if err := env.UnmarshalPayload(&p); err == nil && p.Kind == api.JobPrune { + copy := p + prunePayload = © + } + } + } + + if !sawAdminPush { + t.Error("expected config.update(slot=admin) before prune dispatch") + } + if prunePayload == nil { + t.Fatal("timed out waiting for command.run(prune)") + } + if !prunePayload.RequiresAdminCreds { + t.Error("prune command.run must have RequiresAdminCreds=true") + } + if prunePayload.JobID != jobID { + t.Errorf("job_id mismatch: dispatch=%s run=%s", jobID, prunePayload.JobID) + } + + // Job row must be persisted. + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM jobs WHERE id = ? AND host_id = ? AND kind = 'prune'`, + jobID, hostID).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Errorf("prune job row count: want 1, got %d", n) + } +} + +// ----- check tests --------------------------------------------------- + +// TestRunCheckUsesMaintenanceSubset: check_subset_pct=25 → Args==["25"]. +func TestRunCheckUsesMaintenanceSubset(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + hostID, token := enrolHostForWS(t, srv, st, "check-subset") + cookie := loginAsAdmin(t, st) + setMaintenanceSubset(t, st, hostID, 25) + seedInitJob(t, st, hostID) + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "check-subset") + _ = drainUntil(t, c, api.MsgScheduleSet) + + status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/check", nil, cookie) + if status != stdhttp.StatusAccepted { + t.Fatalf("want 202, got %d body=%+v", status, body) + } + + p := drainCommandRun(t, c) + if p.Kind != api.JobCheck { + t.Fatalf("kind: want check, got %s", p.Kind) + } + if len(p.Args) != 1 || p.Args[0] != "25" { + t.Errorf("args: want [25], got %v", p.Args) + } +} + +// TestRunCheckHonorsSubsetOverride: ?subset=10 overrides DB value of 25. +func TestRunCheckHonorsSubsetOverride(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + hostID, token := enrolHostForWS(t, srv, st, "check-override") + cookie := loginAsAdmin(t, st) + setMaintenanceSubset(t, st, hostID, 25) + seedInitJob(t, st, hostID) + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "check-override") + _ = drainUntil(t, c, api.MsgScheduleSet) + + status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/check?subset=10", nil, cookie) + if status != stdhttp.StatusAccepted { + t.Fatalf("want 202, got %d body=%+v", status, body) + } + + p := drainCommandRun(t, c) + if len(p.Args) != 1 || p.Args[0] != "10" { + t.Errorf("args: want [10], got %v", p.Args) + } +} + +// TestRunCheckRejectsBadSubsetGracefully: ?subset=abc falls back to DB +// value (not an error). strconv.Atoi failure silently ignored. +func TestRunCheckRejectsBadSubsetGracefully(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + hostID, token := enrolHostForWS(t, srv, st, "check-badsubset") + cookie := loginAsAdmin(t, st) + setMaintenanceSubset(t, st, hostID, 30) + seedInitJob(t, st, hostID) + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "check-badsubset") + _ = drainUntil(t, c, api.MsgScheduleSet) + + status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/check?subset=abc", nil, cookie) + if status != stdhttp.StatusAccepted { + t.Fatalf("want 202 (bad subset falls back), got %d body=%+v", status, body) + } + + p := drainCommandRun(t, c) + if len(p.Args) != 1 || p.Args[0] != strconv.Itoa(30) { + t.Errorf("args: want [30], got %v", p.Args) + } +} + +// ----- unlock tests -------------------------------------------------- + +// TestRunUnlockNeedsNoAdminCreds: no admin creds, POST unlock → 202. +func TestRunUnlockNeedsNoAdminCreds(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + hostID, token := enrolHostForWS(t, srv, st, "unlock-no-admin") + cookie := loginAsAdmin(t, st) + seedInitJob(t, st, hostID) + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "unlock-no-admin") + _ = drainUntil(t, c, api.MsgScheduleSet) + + status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/unlock", nil, cookie) + if status != stdhttp.StatusAccepted { + t.Fatalf("want 202, got %d body=%+v", status, body) + } + + p := drainCommandRun(t, c) + if p.Kind != api.JobUnlock { + t.Fatalf("kind: want unlock, got %s", p.Kind) + } + // RequiresAdminCreds must be false for unlock. + if p.RequiresAdminCreds { + t.Error("unlock must not set RequiresAdminCreds") + } +} + +// ----- auth tests ---------------------------------------------------- + +// TestRunOpsRequireAuth: unauthenticated POST to each endpoint → 401. +func TestRunOpsRequireAuth(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + hostID := makeHost(t, st, "auth-host") + + for _, path := range []string{ + "/api/hosts/" + hostID + "/repo/prune", + "/api/hosts/" + hostID + "/repo/check", + "/api/hosts/" + hostID + "/repo/unlock", + } { + path := path + t.Run(path, func(t *testing.T) { + t.Parallel() + req, _ := stdhttp.NewRequest("POST", url+path, nil) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusUnauthorized { + t.Errorf("want 401, got %d", res.StatusCode) + } + }) + } +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 7bf9090..5a34699 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -141,12 +141,23 @@ func (s *Server) routes(r chi.Router) { // 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) + + // Repo-level run-now: prune (needs admin creds), check, unlock. + // HTMX forms are also mounted outside /api below. + r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune) + r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck) + r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock) }) // 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) + // Repo-level run-now (HTMX form actions). Same handlers as the /api + // variants — wantsHTML sniff distinguishes JSON vs HTMX response. + r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune) + r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck) + r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock) // 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.