P3-01/02/03: restore wizard backend + templates + restore-shaped job page

End-to-end wizard from /hosts/{id}/restore (or per-snapshot deep link
/hosts/{id}/snapshots/{sid}/restore) → tree-browse → dispatch →
restore-shaped live job page.

Backend (internal/server/http/ui_restore.go):
- GET handlers render the four-step wizard against the wireframe shape
  in docs/superpowers/specs/2026-05-04-p3-restore-design.md.
- HTMX tree partial endpoint hits fetchTreeWithCache (P3-X2) so each
  directory expansion is a sub-second cached lookup after the first
  miss.
- POST validates: snapshot_id non-empty, ≥1 absolute path, in-place
  mode requires confirm_hostname == host name, agent online. On error
  re-renders the wizard with the operator's input intact. Happy path
  mints a job_id, computes the new-directory target as
  /var/restic-restore/<job-id>/ (operator can't escape the prefix —
  server picks it), creates the job row, ships command.run with
  kind=restore + RestorePayload, writes a host.restore audit row,
  returns HX-Redirect (or 303) to the live job page.

Templates:
- host_restore.html: single-page progressively-enabled wizard matching
  _diag/p3-restore-wizard wireframe. Form-state-driven JS computes a
  running tally of selected paths and the step-4 confirm summary
  client-side; the server re-renders on validation failure with form
  fields preserved.
- partials/tree_node.html: recursive HTMX-served tree fragment.
- Top-level Restore button on host_detail right rail + per-snapshot
  Restore action on snapshot rows replace the previous P3-stub.

Restore-shaped job page (job_detail.html):
- Progress widget rendered as a panel rather than a bare strip when
  the job is active.
- Current-file display under the bar, updated from log.stream stdout
  lines that look like absolute paths. Hidden for non-restore kinds.

Migration 0012:
- Add restore + diff to the jobs.kind CHECK. Rebuild required (SQLite
  can't ALTER CHECK in place); follows the safe pattern from 0005.
  Defensive: stash job_logs into a temp table before the rebuild and
  INSERT OR IGNORE back afterwards so even if SQLite cascades on
  DROP TABLE jobs the log history survives.

Tests:
- ui_restore_test covers GET step-1 render, GET pre-selected snapshot
  summary card, POST missing snapshot, POST missing paths, POST
  in-place wrong-hostname rejection (no command.run leaks to the
  agent), POST happy path (HX-Redirect + correct payload + audit
  row), POST against offline host returns 503.

Restage block (CLAUDE.md) deferred to the end of the restore phase.
This commit is contained in:
2026-05-04 15:34:29 +01:00
parent f5e3bca6a2
commit 4c108bb68a
9 changed files with 1249 additions and 4 deletions
+6
View File
@@ -282,6 +282,12 @@ func (s *Server) routes(r chi.Router) {
r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun)
// Live job log.
r.Get("/jobs/{id}", s.handleUIJobDetail)
// Restore wizard (P3-01/P3-02). Two GET variants land on the
// same handler; the second deep-links a chosen snapshot.
r.Get("/hosts/{id}/restore", s.handleUIRestoreGet)
r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet)
r.Post("/hosts/{id}/restore", s.handleUIRestorePost)
r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree)
}
// Browser job-log stream (separate from /ws/agent so the auth
+423
View File
@@ -0,0 +1,423 @@
package http
import (
"context"
"errors"
"log/slog"
stdhttp "net/http"
"path"
"sort"
"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/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// ui_restore.go — restore wizard backend (P3-01).
//
// GET /hosts/{id}/restore wizard step 1 (snapshot picker)
// GET /hosts/{id}/snapshots/{sid}/restore wizard with snapshot pre-selected
// GET /hosts/{id}/restore/tree HTMX partial: one tree node + children
// POST /hosts/{id}/restore dispatch the restore job
// hostRestorePage is the model for the wizard template.
type hostRestorePage struct {
hostChromeData
// Snapshot picker rows; rendered by the template into the step-1
// table. Limited to most-recent N (the operator can refine on
// snapshot ID if they need an older one — out of scope for v1).
Snapshots []store.Snapshot
// Selected is non-nil iff a snapshot has been chosen — either via
// the deep-link path /hosts/{id}/snapshots/{sid}/restore or by a
// previous form submission that the wizard re-rendered.
Selected *store.Snapshot
// Default target dir — surfaced in the step-3 radio card.
DefaultTargetDir string
// Online mirrors Hub.Connected so the dispatch button can be
// disabled at render time when the agent is offline.
Online bool
// Error is shown as a banner above the wizard. Re-render-friendly:
// the operator's snapshot/path/target choices survive the round-trip.
Error string
// Form fields preserved on validation re-render. The template
// reads these to pre-tick checkboxes etc; the names match the
// POST form keys.
FormPaths []string // "/etc/nginx/sites-available/alfa.conf"
FormInPlace bool
FormTargetDir string
FormConfirmHN string // typed-confirm input value
}
// handleUIRestoreGet renders the wizard. URL variants:
// - /hosts/{id}/restore — step 1 = pick snapshot
// - /hosts/{id}/snapshots/{sid}/restore — snapshot pre-selected
func (s *Server) handleUIRestoreGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
hostID := chi.URLParam(r, "id")
host, err := s.deps.Store.GetHost(r.Context(), hostID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
slog.Error("ui restore: get host", "host_id", hostID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
page := hostRestorePage{
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "restore"),
DefaultTargetDir: defaultRestoreTargetDir(),
Online: s.deps.Hub.Connected(host.ID),
}
snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
if err != nil {
slog.Error("ui restore: list snapshots", "host_id", hostID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if len(snaps) > 100 {
snaps = snaps[:100]
}
page.Snapshots = snaps
// Snapshot deep-link variant — if the URL carries a sid, prefill it.
if sid := chi.URLParam(r, "sid"); sid != "" {
for i := range snaps {
if snaps[i].ID == sid || snaps[i].ShortID == sid {
p := snaps[i]
page.Selected = &p
break
}
}
}
view := s.baseView(u)
view.Title = "Restore · " + host.Name
view.Page = page
if err := s.deps.UI.Render(w, "host_restore", view); err != nil {
slog.Error("ui restore: render", "err", err)
}
}
// handleUIRestorePost validates the form and dispatches the restore
// job. On validation error re-renders the wizard with the error
// banner + the operator's input intact.
func (s *Server) handleUIRestorePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
hostID := chi.URLParam(r, "id")
host, err := s.deps.Store.GetHost(r.Context(), hostID)
if err != nil {
stdhttp.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad form", stdhttp.StatusBadRequest)
return
}
snapshotID := strings.TrimSpace(r.PostForm.Get("snapshot_id"))
paths := r.PostForm["paths"] // multiple checkbox values
inPlace := r.PostForm.Get("target_mode") == "in_place"
targetDir := strings.TrimSpace(r.PostForm.Get("target_dir"))
confirmHN := strings.TrimSpace(r.PostForm.Get("confirm_hostname"))
rerender := func(errMsg string, status int) {
page := hostRestorePage{
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "restore"),
DefaultTargetDir: defaultRestoreTargetDir(),
Online: s.deps.Hub.Connected(host.ID),
Error: errMsg,
FormPaths: paths,
FormInPlace: inPlace,
FormTargetDir: targetDir,
FormConfirmHN: confirmHN,
}
snaps, _ := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
if len(snaps) > 100 {
snaps = snaps[:100]
}
page.Snapshots = snaps
for i := range snaps {
if snaps[i].ID == snapshotID || snaps[i].ShortID == snapshotID {
ss := snaps[i]
page.Selected = &ss
break
}
}
view := s.baseView(u)
view.Title = "Restore · " + host.Name
view.Page = page
w.WriteHeader(status)
_ = s.deps.UI.Render(w, "host_restore", view)
}
if snapshotID == "" {
rerender("Pick a snapshot first.", stdhttp.StatusUnprocessableEntity)
return
}
cleanPaths := make([]string, 0, len(paths))
for _, p := range paths {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if !strings.HasPrefix(p, "/") {
rerender("Paths must be absolute (start with /).", stdhttp.StatusUnprocessableEntity)
return
}
cleanPaths = append(cleanPaths, p)
}
if len(cleanPaths) == 0 {
rerender("Pick at least one file or directory to restore.", stdhttp.StatusUnprocessableEntity)
return
}
if inPlace {
if confirmHN != host.Name {
rerender("Type the host name exactly to confirm an in-place (overwrite) restore.",
stdhttp.StatusUnprocessableEntity)
return
}
} else {
// New-directory mode: server picks the path so the operator
// can't escape /var/restic-restore. Operator-supplied
// target_dir is intentionally ignored.
targetDir = ""
}
if !s.deps.Hub.Connected(host.ID) {
rerender("Agent is offline. Try again when it reconnects.",
stdhttp.StatusServiceUnavailable)
return
}
// Build a new job id up-front so we can substitute it into the
// new-directory target path. The dispatch helper will use this
// same id (mint=now → reuse via dispatchJobWithPayload's
// signature requires the id, so do it here and pass on).
jobID := ulid.Make().String()
finalTarget := ""
if !inPlace {
finalTarget = path.Join(defaultRestoreTargetRoot(), jobID)
}
now := time.Now().UTC()
if err := s.deps.Store.CreateJob(r.Context(), store.Job{
ID: jobID,
HostID: host.ID,
Kind: string(api.JobRestore),
ActorKind: "user",
ActorID: &u.ID,
CreatedAt: now,
}); err != nil {
slog.Error("ui restore: create job", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
payload := api.CommandRunPayload{
JobID: jobID,
Kind: api.JobRestore,
Restore: &api.RestorePayload{
SnapshotID: snapshotID,
Paths: cleanPaths,
InPlace: inPlace,
TargetDir: finalTarget,
},
}
env, err := api.Marshal(api.MsgCommandRun, jobID, payload)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := s.deps.Hub.Send(r.Context(), host.ID, env); err != nil {
slog.Warn("ui restore: dispatch failed", "err", err)
rerender("Couldn't deliver the restore command (agent went offline).",
stdhttp.StatusServiceUnavailable)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
UserID: &u.ID,
Actor: "user",
Action: "host.restore",
TargetKind: ptr("host"),
TargetID: &host.ID,
TS: now,
})
// HTMX redirect (or vanilla redirect) to the live job log.
jobURL := "/jobs/" + jobID
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", jobURL)
w.WriteHeader(stdhttp.StatusNoContent)
return
}
stdhttp.Redirect(w, r, jobURL, stdhttp.StatusSeeOther)
}
// hostRestoreTreePage is the data shape for the tree-node HTMX partial.
type hostRestoreTreePage struct {
HostID string
SnapshotID string
Path string
Children []treeChildView
Error string
}
// treeChildView is one row of the tree (a direct child of Path).
type treeChildView struct {
Name string
Type string // dir | file | symlink
Path string // full path, used in the checkbox value
Size int64
IsDir bool
}
// handleUIRestoreTree is the HTMX-served partial that loads one
// directory's children. Called when the operator clicks an expand
// chevron in the wizard's tree browser. Caches via fetchTreeWithCache.
func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
hostID := chi.URLParam(r, "id")
host, err := s.deps.Store.GetHost(r.Context(), hostID)
if err != nil {
stdhttp.NotFound(w, r)
return
}
q := r.URL.Query()
snapshotID := strings.TrimSpace(q.Get("snapshot"))
pathArg := strings.TrimSpace(q.Get("path"))
if pathArg == "" {
pathArg = "/"
}
if snapshotID == "" {
stdhttp.Error(w, "snapshot required", stdhttp.StatusBadRequest)
return
}
if !s.deps.Hub.Connected(host.ID) {
// Render the partial with an error message rather than 503ing
// — the wizard renders the error inline next to the failed node.
page := hostRestoreTreePage{
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
Error: "agent offline",
}
view := s.baseView(u)
view.Page = page
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
return
}
sessionID := sessionIDFromCookie(r)
ctx, cancel := context.WithTimeout(r.Context(), 35*time.Second)
defer cancel()
result, err := s.fetchTreeWithCache(ctx, sessionID, host.ID, snapshotID, pathArg)
if err != nil {
page := hostRestoreTreePage{
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
Error: err.Error(),
}
view := s.baseView(u)
view.Page = page
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
return
}
if result.Error != "" {
page := hostRestoreTreePage{
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
Error: result.Error,
}
view := s.baseView(u)
view.Page = page
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
return
}
children := make([]treeChildView, 0, len(result.Entries))
for _, e := range result.Entries {
full := joinTreePath(pathArg, e.Name)
children = append(children, treeChildView{
Name: e.Name, Type: e.Type, Path: full,
Size: e.Size,
IsDir: e.Type == "dir",
})
}
// Stable order: dirs first, then files, alphabetically.
sort.SliceStable(children, func(i, j int) bool {
if children[i].IsDir != children[j].IsDir {
return children[i].IsDir
}
return children[i].Name < children[j].Name
})
page := hostRestoreTreePage{
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
Children: children,
}
view := s.baseView(u)
view.Page = page
if err := s.deps.UI.RenderPartial(w, "tree_node", view); err != nil {
slog.Warn("ui restore tree: render partial", "err", err)
}
}
// defaultRestoreTargetRoot is the parent of the per-job restore
// directory. Chosen on a per-host basis would be nicer but the agent
// is the one that actually creates it, and /var/restic-restore is
// fine for Linux hosts (the agent's systemd unit runs as root).
func defaultRestoreTargetRoot() string {
return "/var/restic-restore"
}
// defaultRestoreTargetDir surfaces the placeholder path shown on the
// step-3 New-directory radio card. The "<job-id>" is not substituted
// here — that happens at dispatch time.
func defaultRestoreTargetDir() string {
return defaultRestoreTargetRoot() + "/<job-id>/"
}
// sessionIDFromCookie returns the operator's session cookie value,
// used as the cache key scope for the tree-list cache. Unauthenticated
// requests don't reach this point, so an empty cookie value would
// only happen if requireUIUser is bypassed in tests — fall back to
// the request remote addr for those cases.
func sessionIDFromCookie(r *stdhttp.Request) string {
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
return c.Value
}
return r.RemoteAddr
}
// joinTreePath combines a directory path and a child name into an
// absolute snapshot-relative path, normalising any duplicate slashes.
func joinTreePath(dir, name string) string {
if dir == "" || dir == "/" {
return "/" + name
}
return strings.TrimRight(dir, "/") + "/" + name
}
// satisfy unused-import if compile order shifts.
var _ = ui.User{}
+354
View File
@@ -0,0 +1,354 @@
// ui_restore_test.go — covers the restore wizard backend (P3-01).
package http
import (
"context"
"encoding/json"
stdhttp "net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// seedSnapshot creates a snapshot row directly via ReplaceHostSnapshots.
// Returns the snapshot ID.
func seedSnapshot(t *testing.T, st *store.Store, hostID, hostname string) string {
t.Helper()
id := strings.ReplaceAll(ulid.Make().String(), "-", "")
short := id[:8]
if err := st.ReplaceHostSnapshots(context.Background(), hostID, []store.Snapshot{{
ID: id, ShortID: short, Time: time.Now().UTC().Add(-2 * time.Hour),
Hostname: hostname, Paths: []string{"/etc"}, Tags: []string{"system-config"},
SizeBytes: 612 * 1024 * 1024, FileCount: 100,
}}, time.Now().UTC()); err != nil {
t.Fatalf("seed snapshot: %v", err)
}
return id
}
// TestRestoreWizardGetRendersStep1 verifies the snapshot picker is on
// the page when no snapshot is pre-selected.
func TestRestoreWizardGetRendersStep1(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServerWithUI(t)
hostID, _ := enrolHostForUI(t, srv, st, "rstore-host-1")
_ = seedSnapshot(t, st, hostID, "rstore-host-1")
cookie := loginAsAdmin(t, st)
req, _ := stdhttp.NewRequest("GET", ts.URL+"/hosts/"+hostID+"/restore", nil)
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.StatusOK {
t.Fatalf("status: got %d, want 200", res.StatusCode)
}
body := readBody(t, res.Body)
if !strings.Contains(body, "Restore from snapshot") {
t.Errorf("expected wizard heading; body: %s", short(body))
}
if !strings.Contains(body, "Pick a snapshot first") &&
!strings.Contains(body, "Pick the point-in-time you want to restore from") {
t.Errorf("expected step-1 prompt")
}
}
// TestRestoreWizardGetWithSnapshotPreselected verifies the deep-link
// path puts the snapshot summary card on the page.
func TestRestoreWizardGetWithSnapshotPreselected(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServerWithUI(t)
hostID, _ := enrolHostForUI(t, srv, st, "rstore-host-2")
sid := seedSnapshot(t, st, hostID, "rstore-host-2")
cookie := loginAsAdmin(t, st)
req, _ := stdhttp.NewRequest("GET",
ts.URL+"/hosts/"+hostID+"/snapshots/"+sid+"/restore", nil)
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.StatusOK {
t.Fatalf("status: got %d", res.StatusCode)
}
body := readBody(t, res.Body)
// The selected summary card should reference the snapshot's short ID.
if !strings.Contains(body, sid[:8]) {
t.Errorf("expected snapshot short id in body")
}
if !strings.Contains(body, "picked from") {
t.Errorf("expected 'picked from N snapshots' summary line")
}
}
// TestRestorePostRequiresSnapshot: form without snapshot_id re-renders
// with an error.
func TestRestorePostRequiresSnapshot(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServerWithUI(t)
hostID, _ := enrolHostForUI(t, srv, st, "rstore-no-snap")
cookie := loginAsAdmin(t, st)
form := url.Values{
"snapshot_id": {""},
"target_mode": {"new_dir"},
"paths": {"/etc/foo"},
}
req, _ := stdhttp.NewRequest("POST",
ts.URL+"/hosts/"+hostID+"/restore", 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)
}
body := readBody(t, res.Body)
if !strings.Contains(body, "Pick a snapshot") {
t.Errorf("expected 'Pick a snapshot' error in body")
}
}
// TestRestorePostRequiresPaths: form with snapshot but no paths is rejected.
func TestRestorePostRequiresPaths(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServerWithUI(t)
hostID, _ := enrolHostForUI(t, srv, st, "rstore-no-paths")
sid := seedSnapshot(t, st, hostID, "rstore-no-paths")
cookie := loginAsAdmin(t, st)
form := url.Values{
"snapshot_id": {sid},
"target_mode": {"new_dir"},
}
req, _ := stdhttp.NewRequest("POST",
ts.URL+"/hosts/"+hostID+"/restore", 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)
}
body := readBody(t, res.Body)
if !strings.Contains(body, "at least one file") {
t.Errorf("expected paths-required error")
}
}
// TestRestorePostInPlaceRequiresHostnameMatch: in-place mode with the
// wrong hostname typed re-renders + does not dispatch.
func TestRestorePostInPlaceRequiresHostnameMatch(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServerWithUI(t)
hostID, token := enrolHostForUI(t, srv, st, "rstore-inplace")
sid := seedSnapshot(t, st, hostID, "rstore-inplace")
c := agentDial(t, srv, ts, hostID, token)
sendHello(t, c, "rstore-inplace")
_ = drainUntil(t, c, api.MsgScheduleSet)
cookie := loginAsAdmin(t, st)
form := url.Values{
"snapshot_id": {sid},
"target_mode": {"in_place"},
"paths": {"/etc/nginx/nginx.conf"},
"confirm_hostname": {"WRONG"},
}
req, _ := stdhttp.NewRequest("POST",
ts.URL+"/hosts/"+hostID+"/restore", 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)
}
// No restore command should arrive at the agent.
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
for {
mt, raw, rerr := c.Read(ctx)
if rerr != nil {
break
}
if mt == websocket.MessageText && strings.Contains(string(raw), `"command.run"`) &&
strings.Contains(string(raw), `"kind":"restore"`) {
t.Fatal("unexpected restore command.run after wrong-hostname rejection")
}
}
}
// TestRestorePostHappyPathDispatches: well-formed new-directory form
// dispatches a JobRestore command.run with the expected payload + writes
// an audit row + redirects.
func TestRestorePostHappyPathDispatches(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServerWithUI(t)
hostID, token := enrolHostForUI(t, srv, st, "rstore-happy")
sid := seedSnapshot(t, st, hostID, "rstore-happy")
c := agentDial(t, srv, ts, hostID, token)
sendHello(t, c, "rstore-happy")
_ = drainUntil(t, c, api.MsgScheduleSet)
cookie := loginAsAdmin(t, st)
form := url.Values{
"snapshot_id": {sid},
"target_mode": {"new_dir"},
"paths": {"/etc/nginx/nginx.conf", "/etc/nginx/sites-available/alfa.conf"},
}
req, _ := stdhttp.NewRequest("POST",
ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
req.AddCookie(cookie)
// Don't follow redirects — we want to inspect the HX-Redirect header.
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 header pointing at the live job page")
}
// Find the dispatched command.run on the agent socket.
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), `"command.run"`) || !strings.Contains(string(raw), `"kind":"restore"`) {
continue
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
break
}
if got.Type != api.MsgCommandRun {
t.Fatal("never received restore command.run")
}
var cp api.CommandRunPayload
if err := got.UnmarshalPayload(&cp); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if cp.Kind != api.JobRestore {
t.Fatalf("kind: got %q", cp.Kind)
}
if cp.Restore == nil {
t.Fatal("restore payload is nil")
}
if cp.Restore.SnapshotID != sid {
t.Fatalf("snapshot id: got %q want %q", cp.Restore.SnapshotID, sid)
}
if cp.Restore.InPlace {
t.Fatal("expected new-directory mode (in_place=false)")
}
if !strings.HasPrefix(cp.Restore.TargetDir, "/var/restic-restore/") {
t.Fatalf("target_dir: got %q, want prefix /var/restic-restore/", cp.Restore.TargetDir)
}
if len(cp.Restore.Paths) != 2 {
t.Fatalf("paths: got %d, want 2", len(cp.Restore.Paths))
}
// Audit row.
var n int
if err := st.DB().QueryRow(
`SELECT COUNT(*) FROM audit_log WHERE action = 'host.restore' AND target_id = ?`,
hostID).Scan(&n); err != nil {
t.Fatalf("audit count: %v", err)
}
if n != 1 {
t.Fatalf("audit rows: got %d, want 1", n)
}
}
// TestRestorePostOfflineHostRejected: agent not connected → 503 +
// no command.run.
func TestRestorePostOfflineHostRejected(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServerWithUI(t)
hostID, _ := enrolHostForUI(t, srv, st, "rstore-offline")
sid := seedSnapshot(t, st, hostID, "rstore-offline")
cookie := loginAsAdmin(t, st)
form := url.Values{
"snapshot_id": {sid},
"target_mode": {"new_dir"},
"paths": {"/etc/foo"},
}
req, _ := stdhttp.NewRequest("POST",
ts.URL+"/hosts/"+hostID+"/restore", 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.StatusServiceUnavailable {
t.Fatalf("status: got %d, want 503", res.StatusCode)
}
_ = srv
}
// helpers --------------------------------------------------------------
func readBody(t *testing.T, body interface{ Read(p []byte) (int, error) }) string {
t.Helper()
buf := make([]byte, 0, 16*1024)
tmp := make([]byte, 4096)
for {
n, err := body.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
}
if err != nil {
break
}
}
return string(buf)
}
func short(s string) string {
if len(s) > 400 {
return s[:400] + "…"
}
return s
}
+1
View File
@@ -92,6 +92,7 @@ func New() (*Renderer, error) {
"templates/partials/toast.html",
"templates/partials/awaiting_agent.html",
"templates/partials/host_chrome.html",
"templates/partials/tree_node.html",
}
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
@@ -0,0 +1,61 @@
-- 0012_jobs_restore_diff_kind.sql
--
-- Add 'restore' and 'diff' to the jobs.kind CHECK constraint so the
-- restore wizard (P3-01) and the snapshot-diff endpoint (P3-09) can
-- persist their job rows. SQLite can't ALTER a CHECK in place, so we
-- rebuild the table.
--
-- Rebuild safety: jobs has an inbound FK from job_logs (ON DELETE
-- CASCADE) and from schedules.jobs is referenced via scheduled_id.
-- CLAUDE.md flags DROP TABLE on a parent as risky under
-- foreign_keys=ON; we mitigate two ways:
--
-- 1. Stash job_logs into a temp table BEFORE rebuilding jobs, then
-- restore the rows after the rebuild settles. If a cascade
-- misbehaves we can still recover.
-- 2. Use the safe rebuild order from 0005: create jobs_new with the
-- wider CHECK → copy data → DROP jobs → RENAME jobs_new TO jobs.
-- Do NOT rename the original first (the dangling-FK trap that
-- 0005's first draft hit and 0006 cleaned up).
CREATE TEMPORARY TABLE _job_logs_backup AS
SELECT job_id, seq, ts, stream, payload FROM job_logs;
CREATE TABLE jobs_new (
id TEXT PRIMARY KEY,
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN
('backup','init','forget','prune','check','unlock','restore','diff')),
status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
scheduled_id TEXT REFERENCES schedules(id) ON DELETE SET NULL,
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user','schedule','system')),
actor_id TEXT,
started_at TEXT,
finished_at TEXT,
exit_code INTEGER,
stats TEXT,
error TEXT,
created_at TEXT NOT NULL
);
INSERT INTO jobs_new
SELECT id, host_id, kind, status, scheduled_id, actor_kind, actor_id,
started_at, finished_at, exit_code, stats, error, created_at
FROM jobs;
DROP TABLE jobs;
ALTER TABLE jobs_new RENAME TO jobs;
CREATE INDEX jobs_host_id ON jobs(host_id);
CREATE INDEX jobs_status ON jobs(status);
CREATE INDEX jobs_created_at ON jobs(created_at);
-- Defensive: if cascade-on-DROP wiped job_logs (it shouldn't with the
-- foreign_keys behaviour SQLite documents, but the codebase has hit
-- "lost rows" before during rebuilds), restore from the temp backup.
-- INSERT OR IGNORE so re-running is harmless.
INSERT OR IGNORE INTO job_logs (job_id, seq, ts, stream, payload)
SELECT job_id, seq, ts, stream, payload FROM _job_logs_backup;
DROP TABLE _job_logs_backup;