Phase 3 — Restore (P3-X1, X2, 01, 02, 03, 09, X3-X6) #6

Merged
steve merged 13 commits from p3-restore into main 2026-05-04 18:06:18 +01:00
Owner

Summary

Phase 3, Restore sub-phase. Operator can pick a snapshot, drill into a tree
browser, choose paths, dispatch a restore, watch live progress, cancel
mid-flight, download the log, and (separately) diff two snapshots — all from
the UI.

Spec: docs/superpowers/specs/2026-05-04-p3-restore-design.md. Wireframe:
_diag/p3-restore-wizard/wireframe.html. Sweep screenshots:
_diag/p3-restore-sweep/.

Commits (in order)

  1. docs: P3 restore design spec + scope-decompose Phase 3 (454a241) —
    spec doc, P3-04 cross-host moved to Future / unscheduled.
  2. P3-X1: cancel-job feature (9fa2ef4) — command.cancel envelope, agent
    tracks per-job ctx.CancelFunc, SIGTERM (5s grace) → SIGKILL via
    cmd.Cancel + cmd.WaitDelay. Wires the existing UI Cancel button.
  3. P3-X2: tree.list synchronous WS RPC + per-session cache (6d295bc) —
    generic Hub.SendRPC, restic ls wrapper with direct-children filter,
    30-min in-memory wizard-session cache.
  4. P3-03: restic restore + diff execution path (265b4b6) — JobRestore +
    JobDiff, restic.RunRestore / RunDiff, runner translation,
    RestorePayload / DiffPayload.
  5. P3-01/02/03: restore wizard backend + templates + restore-shaped job
    page
    (6e47efc) — /hosts/{id}/restore four-step wizard, tree partial,
    host.restore audit, restore-shaped progress widget, migration 0012.
  6. P3-09 + P3-X3: snapshot diff + recent-restores line (1111124) —
    POST /hosts/{id}/snapshots/diff + UI panel + last restore chrome line.
  7. P3 sweep fixes (e22b41d) — .snap-row CSS, tree expand JS, default
    target moved into ReadWritePaths-friendly path.
  8. P3 follow-up: log download (txt + ndjson) (a2398d0) — backed by
    job_logs table; works any time, no pause needed against the live WS.
  9. P3 follow-up: editable target dir, conditional --no-ownership, UK
    lint
    (f0dfa68) — \$HOME/rm-restore/<job-id>/ default + agent-side
    expansion, Env.AtLeastVersion(0,17)-gated --no-ownership, UK locale +
    73-correction sweep.
  10. ui: tidy job-page download into a single dropdown (8e06bc7) — native
    details/summary dropdown, reusable .dropdown tokens.
  11. ui: snapshots SIZE/FILES tooltip when host's restic is < 0.17
    (0225067) — LegacyRestic flag + title attribute on column headers.
  12. P3 wrap: agent auto-creates restore target; tasks.md ticked
    (e4031d2) — os.MkdirAll on new-dir restore so operator-typed paths
    just work; tasks.md refreshed with all six P3-X follow-ups.

What's new for operators

  • Restore wizard at /hosts/{id}/restore (or /hosts/{id}/snapshots/{sid}/restore to deep-link).
  • Cancel on any running job (any kind, not just restore).
  • Download log dropdown on every job page — .txt for humans, .ndjson for jq.
  • Diff snapshots panel on the host detail right rail.
  • Recent-restores line in host chrome when there's been a restore.
  • SIZE/FILES tooltip explaining the restic 0.17+ requirement on legacy hosts.

What's deferred

  • P3-04 cross-host restore moved to a new "Future / unscheduled" section in
    tasks.md. Disaster recovery is already covered by re-enrolling a replacement
    host with the same repo creds; the file-sharing use case isn't confirmed.

Migration

Migration 0012 widens the jobs.kind CHECK to include restore and diff.
Rebuild required (SQLite can't ALTER CHECK in place); follows the safe pattern
from 0005, with a defensive temp-table backup of job_logs so the cascade
trap that bit migration 0007 wouldn't take the log history with it.

Restage notes

Per CLAUDE.md, this PR touches agent code, the systemd unit, and install.sh —
the full restage block applies on smoke env. install.sh now pre-creates
/root/rm-restore (root-owned 0700) so the default new-dir target works under
the sandbox out of the box; existing installs need a re-run of install.sh to
pick up the new dir.

Test plan

  • Login → host detail → Restore from snapshot... → wizard step 1 picks a snapshot, step 2 drills into the tree, step 3 leaves the default \$HOME/rm-restore/<job-id>/, step 4 dispatches.
  • Files land at /root/rm-restore/<job-id>/... on the agent host.
  • In-place mode requires typing the host name; wrong name re-renders with the operator's input intact.
  • Operator-typed custom target (any sandbox-writable absolute path) auto-creates intermediate dirs.
  • Cancel button on a running backup transitions the job to cancelled.
  • Download dropdown serves .txt and .ndjson with the right Content-Disposition; both work mid-run and post-run.
  • Snapshot diff panel dispatches a JobDiff against two short IDs; output renders on the live job page.
  • Recent-restores line on host detail reads "last restore · succeeded N ago · job log →" after a successful restore.
  • On a host with restic < 0.17, hovering SIZE / FILES headers in the snapshots table shows the version tooltip.
  • On a host with restic ≥ 0.17, SIZE / FILES populate from the next backup's snapshots.report (no tooltip, no cursor: help).
  • All go test ./... pass; go vet clean.

🤖 Generated with Claude Code

## Summary Phase 3, Restore sub-phase. Operator can pick a snapshot, drill into a tree browser, choose paths, dispatch a restore, watch live progress, cancel mid-flight, download the log, and (separately) diff two snapshots — all from the UI. Spec: `docs/superpowers/specs/2026-05-04-p3-restore-design.md`. Wireframe: `_diag/p3-restore-wizard/wireframe.html`. Sweep screenshots: `_diag/p3-restore-sweep/`. ## Commits (in order) 1. **docs: P3 restore design spec + scope-decompose Phase 3** (`454a241`) — spec doc, P3-04 cross-host moved to Future / unscheduled. 2. **P3-X1: cancel-job feature** (`9fa2ef4`) — `command.cancel` envelope, agent tracks per-job ctx.CancelFunc, SIGTERM (5s grace) → SIGKILL via `cmd.Cancel` + `cmd.WaitDelay`. Wires the existing UI Cancel button. 3. **P3-X2: tree.list synchronous WS RPC + per-session cache** (`6d295bc`) — generic `Hub.SendRPC`, `restic ls` wrapper with direct-children filter, 30-min in-memory wizard-session cache. 4. **P3-03: restic restore + diff execution path** (`265b4b6`) — `JobRestore` + `JobDiff`, `restic.RunRestore` / `RunDiff`, runner translation, `RestorePayload` / `DiffPayload`. 5. **P3-01/02/03: restore wizard backend + templates + restore-shaped job page** (`6e47efc`) — `/hosts/{id}/restore` four-step wizard, tree partial, `host.restore` audit, restore-shaped progress widget, migration 0012. 6. **P3-09 + P3-X3: snapshot diff + recent-restores line** (`1111124`) — `POST /hosts/{id}/snapshots/diff` + UI panel + `last restore` chrome line. 7. **P3 sweep fixes** (`e22b41d`) — `.snap-row` CSS, tree expand JS, default target moved into ReadWritePaths-friendly path. 8. **P3 follow-up: log download (txt + ndjson)** (`a2398d0`) — backed by `job_logs` table; works any time, no pause needed against the live WS. 9. **P3 follow-up: editable target dir, conditional --no-ownership, UK lint** (`f0dfa68`) — `\$HOME/rm-restore/<job-id>/` default + agent-side expansion, `Env.AtLeastVersion(0,17)`-gated `--no-ownership`, UK locale + 73-correction sweep. 10. **ui: tidy job-page download into a single dropdown** (`8e06bc7`) — native `details/summary` dropdown, reusable `.dropdown` tokens. 11. **ui: snapshots SIZE/FILES tooltip when host's restic is < 0.17** (`0225067`) — `LegacyRestic` flag + `title` attribute on column headers. 12. **P3 wrap: agent auto-creates restore target; tasks.md ticked** (`e4031d2`) — `os.MkdirAll` on new-dir restore so operator-typed paths just work; tasks.md refreshed with all six P3-X follow-ups. ## What's new for operators - **Restore wizard** at `/hosts/{id}/restore` (or `/hosts/{id}/snapshots/{sid}/restore` to deep-link). - **Cancel** on any running job (any kind, not just restore). - **Download log** dropdown on every job page — `.txt` for humans, `.ndjson` for `jq`. - **Diff snapshots** panel on the host detail right rail. - **Recent-restores line** in host chrome when there's been a restore. - **SIZE/FILES tooltip** explaining the restic 0.17+ requirement on legacy hosts. ## What's deferred - **P3-04 cross-host restore** moved to a new "Future / unscheduled" section in tasks.md. Disaster recovery is already covered by re-enrolling a replacement host with the same repo creds; the file-sharing use case isn't confirmed. ## Migration Migration 0012 widens the `jobs.kind` CHECK to include `restore` and `diff`. Rebuild required (SQLite can't ALTER CHECK in place); follows the safe pattern from 0005, with a defensive temp-table backup of `job_logs` so the cascade trap that bit migration 0007 wouldn't take the log history with it. ## Restage notes Per CLAUDE.md, this PR touches agent code, the systemd unit, and install.sh — the full restage block applies on smoke env. install.sh now pre-creates `/root/rm-restore` (root-owned 0700) so the default new-dir target works under the sandbox out of the box; existing installs need a re-run of `install.sh` to pick up the new dir. ## Test plan - [ ] Login → host detail → Restore from snapshot... → wizard step 1 picks a snapshot, step 2 drills into the tree, step 3 leaves the default `\$HOME/rm-restore/<job-id>/`, step 4 dispatches. - [ ] Files land at `/root/rm-restore/<job-id>/...` on the agent host. - [ ] In-place mode requires typing the host name; wrong name re-renders with the operator's input intact. - [ ] Operator-typed custom target (any sandbox-writable absolute path) auto-creates intermediate dirs. - [ ] Cancel button on a running backup transitions the job to `cancelled`. - [ ] Download dropdown serves `.txt` and `.ndjson` with the right Content-Disposition; both work mid-run and post-run. - [ ] Snapshot diff panel dispatches a JobDiff against two short IDs; output renders on the live job page. - [ ] Recent-restores line on host detail reads "last restore · succeeded N ago · job log →" after a successful restore. - [ ] On a host with restic < 0.17, hovering SIZE / FILES headers in the snapshots table shows the version tooltip. - [ ] On a host with restic ≥ 0.17, SIZE / FILES populate from the next backup's snapshots.report (no tooltip, no `cursor: help`). - [ ] All `go test ./...` pass; `go vet` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
steve added 12 commits 2026-05-04 17:53:57 +01:00
Splits Phase 3 into three independently-shippable sub-phases (Restore,
Alerts, Audit UI) so they can land in separate PRs with their own brainstorm
→ spec → plan cycles. The Restore sub-phase is up first.

The brainstorm ran on 2026-05-04 and locked the following decisions:

- Single-host restore only this phase. P3-04 (cross-host restore) is moved
  to a new 'Future / unscheduled' section. Disaster recovery is already
  covered by re-enrolling a replacement host with the same repo creds; the
  remaining 'pull a file from host A onto host C' use case is genuinely
  different (file sharing / migration, not DR) and has no confirmed need.
- Default target is /var/restic-restore/<job-id>/ with --no-ownership;
  in-place restore preserves uid/gid/mode and is gated by typed-confirmation
  of the host name (mirroring the repo re-init danger zone).
- Tree browser is the path picker, lazy-loaded via a synchronous WS RPC
  (tree.list) over the existing correlation-ID infrastructure with a
  per-wizard-session in-memory cache (~30 min TTL).
- Single-page wizard with progressively-enabled sections; entry is a
  top-level Restore button on host detail (or per-snapshot Restore action
  for direct deep-link).
- Snapshot diff (P3-09) is a JobDiff JobKind, dispatched like every other
  agent operation; output streams to the standard live job log page.
- Restore-specific live job page variant with files-restored /
  bytes-restored / current-file widget.
- Single-flight per host across all kinds, plus a real cancel-job feature
  (command.cancel WS envelope, agent kills the restic subprocess via
  context cancel + SIGTERM/SIGKILL grace) so the operator can pre-empt a
  long-running backup if they need to restore urgently. Wires the existing
  job_detail Cancel button (which was a UI stub).
- Audit row host.restore on every dispatch + a recent-restores panel on
  host detail. Role gate deferred to P4-03 RBAC.

Wireframe at _diag/p3-restore-wizard/wireframe.html (gitignored —
transient design artefact); screenshot reviewed and approved 2026-05-04.
Wires the existing job_detail Cancel button (which was a UI stub) into
real backend behaviour:

- internal/api already declared MsgCommandCancel + CommandCancelPayload;
  promote those from forward-declarations to a working envelope. Agent
  side: cmd/agent/main.go drops the TODO-stub and gains a per-job
  ctx.CancelFunc map. runJob's switch is refactored around a small
  spawn() helper so each kind's goroutine derives a per-job context,
  registers the cancel, and removes itself on completion regardless of
  outcome. command.cancel looks up the func and fires it.
- internal/agent/runner.sendFinished now takes ctx and rebadges
  ctx.Canceled errors as JobCancelled (exit 130) rather than
  JobFailed. All Run* call sites updated.
- internal/restic.resticCmd sets cmd.Cancel to send SIGTERM (via
  build-tagged sigterm constant; os.Kill on Windows since SIGTERM
  isn't deliverable there) and cmd.WaitDelay=5s for the SIGKILL
  fallback. SIGTERM lets restic remove its lock file before exiting.
- New POST /api/jobs/{id}/cancel server endpoint validates the job
  is non-terminal and the host is online, sends command.cancel via
  the hub, writes a job.cancel audit row, returns 202. The agent's
  resulting job.finished (status=cancelled) is what actually
  transitions the row.

Tests:
- internal/server/http/cancel_test.go covers happy path (envelope
  shape + audit row), 409 for terminal jobs, 404 for missing jobs,
  503 for offline hosts.
- internal/agent/runner/cancel_test.go covers cancel mid-run: a fake
  restic that exec'd into 'sleep 30' is canceled 150ms after start
  and the resulting job.finished reports JobCancelled with exit 130
  in well under the WaitDelay.

Foundational for P3 restore (operator needs to be able to cancel a
running backup if they need to restore urgently). Independently useful
for prune/check/backup that are stuck.
Foundational for the restore wizard's tree browser. The wizard needs to
lazy-load directory contents from a snapshot as the operator drills
down; this lands the transport.

- internal/api adds MsgTreeList (server → agent) + MsgTreeListResult
  (agent → server) with TreeListRequestPayload / TreeListEntry /
  TreeListResultPayload types. Reply correlates by Envelope.ID.
- internal/restic.ListTreeChildren wraps 'restic ls --json' and
  filters its recursive output to direct children of the requested
  path. Parser + path-normalisation + isDirectChild are unit-tested.
- internal/server/ws/rpc.go introduces a generic SendRPC helper on
  Hub: register a buffered channel keyed by ULID, send the request,
  block on ctx.Done()/timeout/reply. Reply routing piggybacks on the
  existing dispatchAgentMessage by adding a MsgTreeListResult case
  that forwards to the registered waiter; if no waiter is registered
  (caller already gave up) the stray reply is dropped quietly.
- cmd/agent gains a tree.list handler that runs ListTreeChildren on a
  fresh per-call context (60s ceiling) and ships the matching
  tree.list.result envelope. Errors surface in result.Error rather
  than as transport failures so the server-side waiter can render a
  sensible UI message.
- internal/server/http/tree_cache.go is the per-wizard-session cache
  layer (~30min TTL, sweep-on-access) that fetchTreeWithCache uses
  before falling through to SendRPC. Cached on success only; agent
  errors aren't cached so a transient failure doesn't poison the
  session.

Tests:
- internal/restic/ls_test.go covers parseLsChildren at root / mid-tree
  / leaf, plus normalizeTreePath and isDirectChild edge cases.
- internal/server/ws/rpc_test.go unit-tests the registry: round-trip,
  release semantics, concurrent waiters, ctx-cancel.
- internal/server/http/tree_rpc_test.go is the full round-trip: server
  SendRPC → fake-agent over a real WS → reply → server gets the
  payload. Plus a timeout test that confirms ~300ms timeouts terminate
  in ~300ms rather than waiting forever.

The cache is plumbed but no UI handler hits fetchTreeWithCache yet —
that lands with P3-01 (wizard backend). The unused-linter is suppressed
via nolint until the wizard wires it in.
Wires JobRestore and JobDiff end-to-end at the agent layer (the wizard
backend that drives this lands in the next slice).

- internal/api: JobRestore + JobDiff JobKind constants. CommandRunPayload
  grows nullable Restore + Diff sub-payloads. RestorePayload carries
  snapshot_id, paths, in_place, target_dir; DiffPayload carries
  snapshot_a + snapshot_b.
- internal/restic.RunRestore wraps 'restic restore <sid> --target ...
  [--no-ownership] [--include p]...' with --json. New pumpRestoreStdout
  parses the per-line status / summary objects (drops raw status from
  log.stream — the throttled job.progress envelope covers it). New
  RestoreStatus + RestoreSummary types mirror restic's wire shape.
- internal/restic.RunDiff wraps 'restic diff --json <a> <b>'.
- internal/agent/runner: RunRestore translates RestoreStatus into
  job.progress (mapping FilesRestored → FilesDone etc) with a small
  estimateETA helper since restic doesn't provide ETA for restore.
  RunDiff is a thin streamHandler wrapper.
- cmd/agent dispatcher gains JobRestore + JobDiff cases. Both reuse
  the spawn() helper from P3-X1 so cancel just works.
- Drive-by fix: lastProgress was initialised to time.Now() so the
  very first status event was suppressed by the 1s throttle if the
  agent reported quickly. Initialise to time.Time{} (zero) so the
  first event always emits. Affects backup + restore.

Tests:
- restore_test covers restore happy path (started → progress →
  finished, kind=restore on the started envelope), in-place argv
  asserts no --no-ownership, new-dir argv asserts --no-ownership +
  --target + --include, diff produces the expected log.stream lines.

Restage block (CLAUDE.md) is deferred to the end of the restore
sub-phase so we restage once with all changes.
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.
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.
Bug fixes from the Playwright sweep against the live smoke server:

1. Snapshot-picker layout. The .snap-row class was used in the wireframe
   but never landed in web/styles/input.css; rows rendered as vertical
   blocks instead of a 6-column grid. Added the token (mirrors host-row
   shape with restore-specific column widths).

2. Tree expansion. hx-target='closest .tree-row + .tree-children' isn't
   a valid HTMX selector — modifiers don't chain. Replaced HTMX-driven
   expansion with a small window.__rmTreeToggle helper that uses plain
   fetch + .tree-pair wrapper structure for trivial sibling lookup.
   Caches loaded state per node.

3. --no-ownership flag dropped. Restic 0.17 introduced --no-ownership;
   0.16 rejects it ('unknown flag') before doing any work. Since the
   agent runs as root in the systemd unit, restored files keep their
   original uid/gid either way and the parent dir is root-owned, so
   the 'cp without sudo' rationale doesn't hold. Drop the flag entirely.

4. Default target dir moved to /var/lib/restic-manager/restore. The
   systemd unit pins ReadWritePaths to /etc/restic-manager +
   /var/lib/restic-manager (with ProtectSystem=strict making the rest
   of /var read-only); writes to /var/restic-restore failed with
   'read-only file system'.

5. Confirm summary HTML escaping. defaultTarget JS literal evaluates
   to a string with literal angle brackets; insertion into innerHTML
   must escape them. Added an inline HTML-escape pass.

tasks.md ticked for the Restore sub-phase with a sweep summary
covering the live end-to-end test.
The diff job's full output streams to the standard live job log page,
which can be a lot of text the operator wants to grep through or paste
into a ticket. Add a Download button.

Source of truth is the persisted job_logs table — works any time
(running or finished) and doesn't need to pause the live WS stream.
The download is 'everything the server has up to right now'; if the
operator wants a fuller snapshot of a still-running job, they hit
Download again.

- New endpoint GET /api/jobs/{id}/log.{txt,ndjson} (chi {format}
  matcher constrained to the two known suffixes). Auth via session
  cookie. 404 on unknown job.
- internal/server/http/job_download.go writeLogsText emits a small
  header + 'HH:MM:SS.mmm  TAG  payload' rows mirroring what the live
  page shows. writeLogsNDJSON emits one self-contained {seq,ts,stream,
  payload} JSON object per line — appending stays valid (each line
  stands alone), and the whole file pipes cleanly into jq. NDJSON is
  newline-delimited JSON; not the same as a JSON array.
- web/templates/pages/job_detail.html grows two header buttons:
  'Download log' (txt) + '.ndjson' ghost variant for tooling.

Tests cover the txt format (header + per-row shape), the ndjson
format (each line round-trips through json.Unmarshal), unknown job
404, unauthenticated 401.
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.
Replace the floating 'Download log' button + bare '.ndjson' link with
one cohesive dropdown menu — same affordance as the rest of the
header, opens to two well-described options.

- Native <details><summary> for keyboard + no-JS support; only the
  click-outside-to-close handler is JS (a few lines).
- New .dropdown / .dropdown-menu / .dropdown-item tokens in
  web/styles/input.css. Reusable for future header menus
  (host-detail overflow, source-group action menus, etc).
- Chevron flips 180 degrees when open via .dropdown[open] selector.
- Each option has a label + a mono hint line explaining when to pick it
  (.txt for humans / paste into a ticket; .ndjson for jq / tooling).
Per-snapshot size + file-count come from the embedded summary block
restic added to 'snapshots --json' in 0.17 (the source comment in
internal/restic/snapshots.go incorrectly said 0.16+). Hosts running
0.16.x leave those columns blank.

- Fix the snapshots.go doc comment: '0.16+' -> '0.17+'.
- hostDetailPage carries a LegacyRestic bool computed from the host's
  reported ResticVersion via Env.AtLeastVersion(0, 17). Empty version
  also counts as legacy (conservative default).
- Template attaches title='Needs restic 0.17+ on the agent host. This
  host runs <ver>.' + cursor:help on the SIZE / FILES headers when
  the flag is true. Hosts already on 0.17+ get no tooltip and no
  extra styling.

A host upgrading restic to 0.17+ gets the columns populated on the
next backup automatically — no further code change needed.
P3 wrap: agent auto-creates restore target; tasks.md ticked
CI / Lint (pull_request) Successful in 35s
CI / Build (linux/amd64) (pull_request) Successful in 20s
CI / Build (windows/amd64) (pull_request) Successful in 1m18s
CI / Build (linux/arm64) (pull_request) Successful in 46s
CI / Test (linux/amd64) (pull_request) Failing after 2m46s
e4031d26fa
1. Agent-side MkdirAll on the new-dir restore target. Restic creates
   missing leaves but won't traverse multiple missing levels, and
   under the systemd sandbox writes outside ReadWritePaths fail
   anyway. Calling os.MkdirAll(target, 0700) before invoking restic
   means the operator never has to pre-create the per-job subdir,
   and a path the sandbox rejects surfaces as a clean
   'restic restore: prepare target ...: read-only file system' error
   in the job log instead of a cryptic restic-side stat failure.

2. tasks.md Phase 3 — Restore section refreshed:
   - P3-X4 added (job log download dropdown — txt + ndjson)
   - P3-X5 added (UK lint locale switch + 73-correction sweep)
   - P3-X6 added (SIZE/FILES tooltip when host's restic < 0.17)
   - P3-03 entry expanded to cover version-gated --no-ownership,
     editable target, $HOME expansion, agent-side MkdirAll
   - As-shipped sweep summary mentions custom-target restore +
     download dropdown + tooltip in addition to the original walk

Test: TestRunRestoreNewDirAutoCreatesTarget seeds a multi-level
target the operator hasn't created and confirms RunRestore mkdir's
the chain before invoking restic.
steve added 1 commit 2026-05-04 18:01:44 +01:00
test: lock-protect fakeSender so -race CI passes
CI / Lint (pull_request) Successful in 31s
CI / Build (linux/amd64) (pull_request) Successful in 20s
CI / Build (linux/arm64) (pull_request) Successful in 19s
CI / Test (linux/amd64) (pull_request) Successful in 1m27s
CI / Build (windows/amd64) (pull_request) Successful in 1m34s
28d5043eb0
The CI runs go test with -race; the agent runner has two pump goroutines
(pumpStdout + pumpStderr) writing through the sender concurrently, and
the unprotected fakeSender slice append raced. The cancel_test had a
local 'safeSender' workaround for the same issue; promote that mutex
onto fakeSender itself so every test in the package is race-clean
without per-test variants.

- fakeSender grows mu sync.Mutex; Send takes/releases. New snapshot()
  helper for tests that want a stable copy.
- cancel_test drops its local safeSender + sync import; uses fakeSender.

Verified: go test -race ./... passes across all packages.
steve merged commit 64861a5fb8 into main 2026-05-04 18:06:18 +01:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/restic-manager#6