a781e95c94
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.
133 lines
4.3 KiB
Go
133 lines
4.3 KiB
Go
// run_group.go — per-source-group Run-now endpoint.
|
|
//
|
|
// POST /hosts/{id}/source-groups/{gid}/run dispatches a backup job
|
|
// against the resolved includes/excludes/retention/tag of the named
|
|
// group. Replaces the old per-host /hosts/{id}/run-backup route (now
|
|
// 410 Gone).
|
|
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"
|
|
)
|
|
|
|
// parseBandwidthOverride pulls optional bandwidth_up_kbps /
|
|
// bandwidth_down_kbps from the request (form or query). Returns nil
|
|
// for any field absent or empty; an explicit "0" produces a non-nil
|
|
// pointer to 0 — i.e., "no cap for this run, even if the host has
|
|
// one set." Non-integers / negatives are rejected with an error.
|
|
func parseBandwidthOverride(r *stdhttp.Request) (up *int, down *int, err error) {
|
|
parse := func(name string) (*int, error) {
|
|
v := r.FormValue(name)
|
|
if v == "" {
|
|
return nil, nil
|
|
}
|
|
n, perr := strconv.Atoi(v)
|
|
if perr != nil {
|
|
return nil, errors.New(name + " must be an integer")
|
|
}
|
|
if n < 0 {
|
|
return nil, errors.New(name + " must be >= 0")
|
|
}
|
|
return &n, nil
|
|
}
|
|
up, err = parse("bandwidth_up_kbps")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
down, err = parse("bandwidth_down_kbps")
|
|
return up, down, err
|
|
}
|
|
|
|
func (s *Server) handleRunSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
user, ok := s.requireUser(r)
|
|
if !ok {
|
|
// HTML callers redirect to login; for JSON return 401.
|
|
if wantsHTML(r) {
|
|
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
|
|
return
|
|
}
|
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
groupID := chi.URLParam(r, "gid")
|
|
g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
s.runGroupError(w, r, stdhttp.StatusNotFound, "group_not_found",
|
|
"source group not found on this host")
|
|
return
|
|
}
|
|
s.runGroupError(w, r, stdhttp.StatusInternalServerError, "internal", "")
|
|
return
|
|
}
|
|
|
|
// Optional per-run bandwidth override. Disclosed in the UI under a
|
|
// <details> "Limit bandwidth for this run" affordance; absent on
|
|
// the wire (and from JSON callers that don't supply it) means
|
|
// "fall back to the host's standing caps."
|
|
upOverride, downOverride, perr := parseBandwidthOverride(r)
|
|
if perr != nil {
|
|
s.runGroupError(w, r, stdhttp.StatusBadRequest, "invalid_value", perr.Error())
|
|
return
|
|
}
|
|
|
|
// Resolve hooks (group → host default → empty). Best-effort host
|
|
// lookup; failure proceeds with no hook rather than block the run.
|
|
var preHook, postHook string
|
|
if host, herr := s.deps.Store.GetHost(r.Context(), hostID); herr == nil {
|
|
preHook, postHook = s.resolveBackupHooks(host, g)
|
|
}
|
|
|
|
// Backup invocations don't consume RetentionPolicy — that lives on
|
|
// forget. Sending the resolved set here would just be dead weight.
|
|
res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobBackup,
|
|
api.CommandRunPayload{
|
|
Includes: g.Includes,
|
|
Excludes: g.Excludes,
|
|
Tag: g.Name,
|
|
BandwidthUpKBps: upOverride,
|
|
BandwidthDownKBps: downOverride,
|
|
PreHook: preHook,
|
|
PostHook: postHook,
|
|
})
|
|
if code != "" {
|
|
s.runGroupError(w, r, status, code, msg)
|
|
return
|
|
}
|
|
if wantsHTML(r) {
|
|
// HTMX action: redirect to the live job log so the operator
|
|
// sees streaming output immediately.
|
|
w.Header().Set("HX-Redirect", "/jobs/"+res.JobID)
|
|
w.WriteHeader(stdhttp.StatusNoContent)
|
|
return
|
|
}
|
|
writeJSON(w, stdhttp.StatusAccepted, res)
|
|
}
|
|
|
|
// runGroupError dispatches an error to JSON callers as the standard
|
|
// envelope; HTMX callers get a 4xx with a plain text body so the
|
|
// browser surfaces it via the existing toast handler.
|
|
func (s *Server) runGroupError(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)
|
|
}
|
|
|
|
// wantsHTML keys off HX-Request only. Browsers sending a default
|
|
// Accept (or curl's `*/*`) get the JSON shape, which is the safer
|
|
// default for non-htmx clients. HTMX always sets HX-Request=true on
|
|
// its action POSTs, so the form path is unambiguous.
|
|
func wantsHTML(r *stdhttp.Request) bool {
|
|
return r.Header.Get("HX-Request") == "true"
|
|
}
|