f0dfa689fe
Three small follow-ups from review:
1. Restore target is now operator-editable. Default value is the
literal '\$HOME/rm-restore/<job-id>/' (agent expands \$HOME at
run time using os.UserHomeDir(); also handles \${HOME} and ~/
prefixes). Operator can replace with any absolute path.
- ui_restore.go validates the input is either absolute or starts
with one of the recognised prefixes; other env-var refs (\$PATH
etc.) are deliberately rejected so operator paths can't pick up
arbitrary agent env values.
- host_restore.html replaces the read-only mono-text display with
a real <input>; help text spells out that \$HOME resolves
agent-side and <job-id> is substituted on dispatch.
- install.sh + the systemd unit prep /root/rm-restore so the
default works under the sandbox: ReadWritePaths gains a soft
'-/root/rm-restore' entry (the '-' makes the bind-mount soft-fail
if missing, but install.sh pre-creates it root-owned 0700).
2. --no-ownership flag now gated on restic version. The flag was
added in restic 0.17 and 0.16 rejects it. Previously dropped it
wholesale — that meant new-dir restores silently preserved
ownership against design intent on 0.17+. Now the agent threads
its detected restic version (sysinfo already collects it) through
runner.Config -> restic.Env, and RunRestore appends --no-ownership
only when AtLeastVersion(0, 17) returns true. 0.16 hosts still
restore with original uid/gid; help text in the wizard explicitly
notes this. The previous 'Original ownership is preserved' copy
was wrong for new-dir mode and is corrected.
3. golangci-lint misspell locale switched US -> UK and the codebase
swept (73 corrections, mostly behaviour/serialise/recognise/honour).
Wire-format ErrorCode 'unauthorized' -> 'unauthorised' is a tiny
contract change but the agent doesn't parse those codes today and
no external API consumers exist yet. Tests passed before + after.
Tests:
- internal/restic/version_test.go covers Env.AtLeastVersion across
edge cases (empty, exact match, patch above, minor below, non-
numeric) and expandHome on \$HOME / \${HOME} / ~/, plus
pass-through for absolute paths and refusal of other env vars.
- ui_restore_test updated: TargetDir now starts '\$HOME/rm-restore/'
with the job_id substituted into the placeholder.
Live verified on the smoke env: default target restored to
/root/rm-restore/<job-id>/ as the agent's expanded \$HOME (2 files,
14 bytes); custom override '/tmp/custom-restore/<job-id>/' restored
into the agent's PrivateTmp namespace (1 file, 6 bytes); both jobs
'succeeded', exit 0.
166 lines
5.2 KiB
Go
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, "unauthorised", "")
|
|
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, "unauthorised", "")
|
|
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, "unauthorised", "")
|
|
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)
|
|
}
|