Files
restic-manager/internal/server/http/repo_ops.go
T
steve b07cb14320 server: populate audit UserID on credential mutations + slog prune push errors
Switch handleSetHostCredentials, handleSetAdminCredentials, and
handleDeleteAdminCredentials from authedUser (bool) to requireUser
(*store.User) so AuditEntry.UserID and Actor are populated correctly.
Add slog.Warn on the non-ErrNotFound pushAdminCredsToAgent path in
handleRunRepoPrune so decrypt/send failures surface in the server log
rather than appearing as a generic host_offline 503.
2026-05-04 10:15:18 +01:00

166 lines
5.2 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"
"log/slog"
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.
slog.Warn("prune: push admin creds failed", "host_id", hostID, "err", err)
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)
}