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.
This commit is contained in:
2026-05-03 22:57:07 +01:00
parent 81a00202d0
commit 1ae567021a
3 changed files with 505 additions and 0 deletions
+163
View File
@@ -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)
}
+331
View File
@@ -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 = &copy
}
}
}
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)
}
})
}
}
+11
View File
@@ -141,12 +141,23 @@ func (s *Server) routes(r chi.Router) {
// mounted at the equivalent path outside /api below — both // mounted at the equivalent path outside /api below — both
// resolve to the same handler, which sniffs HX-Request. // resolve to the same handler, which sniffs HX-Request.
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup) 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 // Per-source-group Run-now (HTMX form action). Available even
// when the server is started without UI templates so REST callers // when the server is started without UI templates so REST callers
// against the non-/api path also work. // against the non-/api path also work.
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup) 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 // Retired routes — see ui_handlers.go for the messages. Mounted
// outside the UI gate so cached browser tabs get a clear 410 // outside the UI gate so cached browser tabs get a clear 410
// even if the server runs without templates. // even if the server runs without templates.