ef2a30a82d
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.
164 lines
5.1 KiB
Go
164 lines
5.1 KiB
Go
// 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)
|
|
}
|