From 1111124573829e8153ef22f3975c59718cacbada Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 15:38:28 +0100 Subject: [PATCH] P3-09 + P3-X3: snapshot diff + recent-restores line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/server/http/diff.go | 150 ++++++++++++++++++++++++ internal/server/http/diff_test.go | 136 +++++++++++++++++++++ internal/server/http/server.go | 8 ++ internal/server/http/ui_handlers.go | 17 +++ internal/server/http/ui_restore_test.go | 22 ++++ web/templates/pages/host_detail.html | 19 +++ web/templates/partials/host_chrome.html | 20 ++++ 7 files changed, 372 insertions(+) create mode 100644 internal/server/http/diff.go create mode 100644 internal/server/http/diff_test.go diff --git a/internal/server/http/diff.go b/internal/server/http/diff.go new file mode 100644 index 0000000..f5f143f --- /dev/null +++ b/internal/server/http/diff.go @@ -0,0 +1,150 @@ +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, + }) +} diff --git a/internal/server/http/diff_test.go b/internal/server/http/diff_test.go new file mode 100644 index 0000000..1c69275 --- /dev/null +++ b/internal/server/http/diff_test.go @@ -0,0 +1,136 @@ +// diff_test.go — covers POST /api/hosts/{id}/snapshots/diff (P3-09). +package http + +import ( + "context" + "encoding/json" + stdhttp "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" +) + +// TestSnapshotDiffHappyPath verifies a valid two-snapshot form ships +// a JobDiff command.run with the right payload. +func TestSnapshotDiffHappyPath(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServerWithUI(t) + hostID, token := enrolHostForUI(t, srv, st, "diff-host") + a, b := seedTwoSnapshots(t, st, hostID, "diff-host") + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "diff-host") + _ = drainUntil(t, c, api.MsgScheduleSet) + cookie := loginAsAdmin(t, st) + + form := url.Values{ + "snapshot_a": {a}, + "snapshot_b": {b}, + } + req, _ := stdhttp.NewRequest("POST", + ts.URL+"/hosts/"+hostID+"/snapshots/diff", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + req.AddCookie(cookie) + client := &stdhttp.Client{ + CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + res, err := client.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusNoContent { + t.Fatalf("status: got %d, want 204", res.StatusCode) + } + if res.Header.Get("HX-Redirect") == "" { + t.Fatal("expected HX-Redirect to live job page") + } + + deadline := time.Now().Add(2 * time.Second) + var got api.Envelope + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + mt, raw, rerr := c.Read(ctx) + cancel() + if rerr != nil { + break + } + if mt != websocket.MessageText { + continue + } + if !strings.Contains(string(raw), `"kind":"diff"`) { + continue + } + _ = json.Unmarshal(raw, &got) + break + } + if got.Type != api.MsgCommandRun { + t.Fatal("never received diff command.run") + } + var cp api.CommandRunPayload + _ = got.UnmarshalPayload(&cp) + if cp.Diff == nil { + t.Fatal("diff payload nil") + } + if cp.Diff.SnapshotA != a || cp.Diff.SnapshotB != b { + t.Fatalf("diff payload: got %+v want a=%s b=%s", cp.Diff, a, b) + } +} + +// TestSnapshotDiffSameID rejects diff(a,a) with 422. +func TestSnapshotDiffSameID(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServerWithUI(t) + hostID, _ := enrolHostForUI(t, srv, st, "diff-same") + a := seedSnapshot(t, st, hostID, "diff-same") + cookie := loginAsAdmin(t, st) + + form := url.Values{"snapshot_a": {a}, "snapshot_b": {a}} + req, _ := stdhttp.NewRequest("POST", + ts.URL+"/hosts/"+hostID+"/snapshots/diff", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusUnprocessableEntity { + t.Fatalf("status: got %d, want 422", res.StatusCode) + } + _ = srv +} + +// TestSnapshotDiffUnknownID rejects ids not in the host's snapshot list. +func TestSnapshotDiffUnknownID(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServerWithUI(t) + hostID, _ := enrolHostForUI(t, srv, st, "diff-unknown") + _ = seedSnapshot(t, st, hostID, "diff-unknown") + cookie := loginAsAdmin(t, st) + + form := url.Values{"snapshot_a": {"deadbeef"}, "snapshot_b": {"cafebabe"}} + req, _ := stdhttp.NewRequest("POST", + ts.URL+"/hosts/"+hostID+"/snapshots/diff", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusUnprocessableEntity { + t.Fatalf("status: got %d, want 422", res.StatusCode) + } + _ = srv +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 66a1b3b..0f14f4b 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -190,8 +190,16 @@ func (s *Server) routes(r chi.Router) { // resulting job.finished (status=canceled) is what flips the // job row. r.Post("/jobs/{id}/cancel", s.handleCancelJob) + + // Snapshot diff (P3-09). Dispatches a JobDiff against two + // snapshots; output streams to the standard live job page. + r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) }) + // HTMX form variant of diff (mounted outside /api so HTMX forms + // can post against it without the api/ prefix). + r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) + // Per-source-group Run-now (HTMX form action). Available even // when the server is started without UI templates so REST callers // against the non-/api path also work. diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index f5d9594..37e71f9 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -512,6 +512,14 @@ type hostChromeData struct { InitStatus string InitAt *time.Time // started_at if non-nil else created_at InitJobID string + + // Latest 'restore' job — surfaced as a small line below the + // init-status one so the operator has at-a-glance visibility into + // recent destructive activity. Empty status means no restore has + // ever run on this host. + RestoreStatus string + RestoreAt *time.Time + RestoreJobID string } // loadHostChrome fetches the per-tab counts that every host-detail tab @@ -542,6 +550,15 @@ func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, cru } d.InitAt = &t } + if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "restore"); err == nil && j != nil { + d.RestoreStatus = j.Status + d.RestoreJobID = j.ID + t := j.CreatedAt + if j.StartedAt != nil { + t = *j.StartedAt + } + d.RestoreAt = &t + } return d } diff --git a/internal/server/http/ui_restore_test.go b/internal/server/http/ui_restore_test.go index 048b082..7624b0a 100644 --- a/internal/server/http/ui_restore_test.go +++ b/internal/server/http/ui_restore_test.go @@ -33,6 +33,28 @@ func seedSnapshot(t *testing.T, st *store.Store, hostID, hostname string) string return id } +// seedTwoSnapshots seeds two snapshots in one ReplaceHostSnapshots call +// so both end up in the host's list. ReplaceHostSnapshots is atomic- +// swap, so calling seedSnapshot twice would only leave the second. +func seedTwoSnapshots(t *testing.T, st *store.Store, hostID, hostname string) (string, string) { + t.Helper() + a := strings.ReplaceAll(ulid.Make().String(), "-", "") + b := strings.ReplaceAll(ulid.Make().String(), "-", "") + if err := st.ReplaceHostSnapshots(context.Background(), hostID, []store.Snapshot{ + { + ID: a, ShortID: a[:8], Time: time.Now().UTC().Add(-3 * time.Hour), + Hostname: hostname, Paths: []string{"/etc"}, Tags: []string{"system-config"}, + }, + { + ID: b, ShortID: b[:8], Time: time.Now().UTC().Add(-1 * time.Hour), + Hostname: hostname, Paths: []string{"/etc"}, Tags: []string{"system-config"}, + }, + }, time.Now().UTC()); err != nil { + t.Fatalf("seed snapshots: %v", err) + } + return a, b +} + // TestRestoreWizardGetRendersStep1 verifies the snapshot picker is on // the page when no snapshot is pre-selected. func TestRestoreWizardGetRendersStep1(t *testing.T) { diff --git a/web/templates/pages/host_detail.html b/web/templates/pages/host_detail.html index 3b866fe..9734d52 100644 --- a/web/templates/pages/host_detail.html +++ b/web/templates/pages/host_detail.html @@ -86,6 +86,25 @@ class="btn btn-block">Restore from snapshot… + {{if gt $host.SnapshotCount 1}} +
+
Compare snapshots
+

+ Diff two snapshots to see what changed. Output streams to a live + job page like a regular run. +

+
+ + + +
+
+ {{end}} +
Danger zone

diff --git a/web/templates/partials/host_chrome.html b/web/templates/partials/host_chrome.html index 9e3f741..41785e1 100644 --- a/web/templates/partials/host_chrome.html +++ b/web/templates/partials/host_chrome.html @@ -121,6 +121,26 @@

{{end}} + {{/* ---------- latest restore line (P3-X3) ---------- */}} + {{if $page.RestoreStatus}} +
+ {{if eq $page.RestoreStatus "succeeded"}} + last restore · succeeded {{relTime $page.RestoreAt}} · + job log → + {{else if eq $page.RestoreStatus "failed"}} + last restore · failed {{relTime $page.RestoreAt}} · + job log → + {{else if eq $page.RestoreStatus "running"}} + restore running… · live log → + {{else if eq $page.RestoreStatus "cancelled"}} + last restore · cancelled {{relTime $page.RestoreAt}} · + job log → + {{else if eq $page.RestoreStatus "queued"}} + restore queued · job {{$page.RestoreJobID}} + {{end}} +
+ {{end}} + {{/* ---------- secondary tabs ---------- */}}
Snapshots {{comma $host.SnapshotCount}}