c417b5e9ab
P3-09 — snapshot diff dispatcher.
- POST /api/hosts/{id}/snapshots/diff (and the unprefixed HTMX-form
variant) takes {snapshot_a, snapshot_b}, validates both belong to
the host (long id / short id / prefix match), checks the agent is
online, mints a JobDiff, ships command.run with DiffPayload, writes
a host.snapshot_diff audit row, returns HX-Redirect to the live
job page (or JSON {job_id, job_url} for REST callers).
- Two-snapshot guard: POSTing diff(a,a) returns 422.
- UI: small panel on the host_detail right rail (visible when the
host has 2+ snapshots) with two short-id inputs and a Diff button.
Output renders on the standard live job page where the operator
reads the per-line diff text directly.
P3-X3 — recent-restores line.
- hostChromeData grows RestoreStatus / RestoreAt / RestoreJobID
populated via store.LatestJobByKind(host_id, 'restore') (already
exists, used by the init line).
- host_chrome.html renders a small line below the existing init-status
one with status-coloured copy + a link to the job log. Hidden when
no restore has ever run on this host.
Tests:
- diff_test covers happy path (correct DiffPayload + HX-Redirect),
same-id rejection (422), unknown-id rejection (422). Adds a
seedTwoSnapshots helper since ReplaceHostSnapshots is atomic-swap
(calling seedSnapshot twice would only leave the second).
Restage block (CLAUDE.md) deferred to the end of the restore phase.
151 lines
4.7 KiB
Go
151 lines
4.7 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
stdhttp "net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/oklog/ulid/v2"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
)
|
|
|
|
// snapshotDiffRequest is the JSON body for POST .../snapshots/diff.
|
|
// Either short or long snapshot IDs are accepted (restic's diff
|
|
// command takes both).
|
|
type snapshotDiffRequest struct {
|
|
SnapshotA string `json:"snapshot_a"`
|
|
SnapshotB string `json:"snapshot_b"`
|
|
}
|
|
|
|
// handleSnapshotDiff dispatches a JobDiff. Output streams as
|
|
// log.stream lines to the standard live job page; the operator reads
|
|
// the diff text directly there. Behaves like the run-now endpoints:
|
|
// 503 if the host is offline, 400 if the IDs are missing, 422 if
|
|
// they're not in the host's snapshot list (we don't want operators
|
|
// running diffs against arbitrary snapshot strings).
|
|
func (s *Server) handleSnapshotDiff(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
user, ok := s.requireUser(r)
|
|
if !ok {
|
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
if err != nil {
|
|
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
|
return
|
|
}
|
|
|
|
var req snapshotDiffRequest
|
|
// HTMX form posts arrive as application/x-www-form-urlencoded;
|
|
// the JSON shape is also accepted for REST callers.
|
|
ct := r.Header.Get("Content-Type")
|
|
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
|
if err := r.ParseForm(); err != nil {
|
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_form", err.Error())
|
|
return
|
|
}
|
|
req.SnapshotA = strings.TrimSpace(r.PostForm.Get("snapshot_a"))
|
|
req.SnapshotB = strings.TrimSpace(r.PostForm.Get("snapshot_b"))
|
|
} else {
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
req.SnapshotA = strings.TrimSpace(req.SnapshotA)
|
|
req.SnapshotB = strings.TrimSpace(req.SnapshotB)
|
|
}
|
|
if req.SnapshotA == "" || req.SnapshotB == "" {
|
|
writeJSONError(w, stdhttp.StatusBadRequest, "missing_snapshot",
|
|
"snapshot_a and snapshot_b are both required")
|
|
return
|
|
}
|
|
if req.SnapshotA == req.SnapshotB {
|
|
writeJSONError(w, stdhttp.StatusUnprocessableEntity, "same_snapshot",
|
|
"diff requires two different snapshots")
|
|
return
|
|
}
|
|
|
|
// Validate the IDs are known to this host. Match on long ID, short
|
|
// ID, or any prefix match — operators sometimes paste a 6-char
|
|
// shortened form.
|
|
snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID)
|
|
if err != nil {
|
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
|
return
|
|
}
|
|
resolveID := func(idOrShort string) string {
|
|
for _, s := range snaps {
|
|
if s.ID == idOrShort || s.ShortID == idOrShort {
|
|
return s.ID
|
|
}
|
|
}
|
|
// Prefix fallback (operator pasted 6 chars of a long id).
|
|
for _, s := range snaps {
|
|
if strings.HasPrefix(s.ID, idOrShort) {
|
|
return s.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
a := resolveID(req.SnapshotA)
|
|
b := resolveID(req.SnapshotB)
|
|
if a == "" || b == "" {
|
|
writeJSONError(w, stdhttp.StatusUnprocessableEntity, "snapshot_not_found",
|
|
"one or both snapshot ids are not in this host's snapshot list")
|
|
return
|
|
}
|
|
|
|
if !s.deps.Hub.Connected(host.ID) {
|
|
writeJSONError(w, stdhttp.StatusServiceUnavailable, "host_offline",
|
|
"agent is not connected; try again when it reconnects")
|
|
return
|
|
}
|
|
|
|
jobID := ulid.Make().String()
|
|
now := time.Now().UTC()
|
|
if err := s.deps.Store.CreateJob(r.Context(), store.Job{
|
|
ID: jobID, HostID: host.ID, Kind: string(api.JobDiff),
|
|
ActorKind: "user", ActorID: &user.ID, CreatedAt: now,
|
|
}); err != nil {
|
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{
|
|
JobID: jobID, Kind: api.JobDiff,
|
|
Diff: &api.DiffPayload{SnapshotA: a, SnapshotB: b},
|
|
})
|
|
if err != nil {
|
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
|
return
|
|
}
|
|
if err := s.deps.Hub.Send(r.Context(), host.ID, env); err != nil {
|
|
writeJSONError(w, stdhttp.StatusServiceUnavailable, "host_offline", err.Error())
|
|
return
|
|
}
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(),
|
|
UserID: &user.ID,
|
|
Actor: "user",
|
|
Action: "host.snapshot_diff",
|
|
TargetKind: ptr("host"),
|
|
TargetID: &host.ID,
|
|
TS: now,
|
|
})
|
|
|
|
jobURL := "/jobs/" + jobID
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Redirect", jobURL)
|
|
w.WriteHeader(stdhttp.StatusNoContent)
|
|
return
|
|
}
|
|
writeJSON(w, stdhttp.StatusAccepted, map[string]string{
|
|
"job_id": jobID,
|
|
"job_url": jobURL,
|
|
})
|
|
}
|