// 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) }