P3-X2: tree.list synchronous WS RPC + per-session cache

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.
This commit is contained in:
2026-05-04 15:19:22 +01:00
parent 94149a7324
commit 13f58bd052
12 changed files with 905 additions and 12 deletions
+140
View File
@@ -0,0 +1,140 @@
package restic
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os/exec"
"path"
"strings"
)
// LsEntry is one node from `restic ls --json`. Restic emits these as
// line-delimited JSON; we keep only the fields the restore wizard
// needs.
type LsEntry struct {
Name string `json:"name"`
Type string `json:"type"`
Path string `json:"path"`
Size int64 `json:"size,omitempty"`
Struct string `json:"struct_type,omitempty"`
}
// ListTreeChildren runs `restic ls --json <snapshot> <dirPath>` and
// returns only the direct children of dirPath. Restic ls is recursive
// by default, so we filter post-hoc — for a typical interactive
// drill-down ("expand /etc/nginx") the subtree is small (a few KB of
// JSON); for huge subtrees this is suboptimal but correct.
//
// The first emitted line is restic's "snapshot" preamble (struct_type
// = "snapshot") which we discard. Subsequent lines are nodes; we
// match on path equal to dirPath + "/" + name (with normalization so
// trailing slashes don't break the comparison).
//
// dirPath="" or "/" lists the snapshot root.
func (e Env) ListTreeChildren(ctx context.Context, snapshotID, dirPath string) ([]LsEntry, error) {
if snapshotID == "" {
return nil, fmt.Errorf("restic ls: snapshot id required")
}
parent := normalizeTreePath(dirPath)
args := []string{"ls", "--json", snapshotID}
if parent != "/" {
args = append(args, parent)
}
cmd := e.resticCmd(ctx, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("restic ls: stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("restic ls: start: %w", err)
}
out, parseErr := parseLsChildren(stdout, parent)
werr := cmd.Wait()
if werr != nil {
var ee *exec.ExitError
if errors.As(werr, &ee) {
return nil, fmt.Errorf("restic ls: exit %d: %s",
ee.ExitCode(), strings.TrimSpace(stderr.String()))
}
return nil, fmt.Errorf("restic ls: %w", werr)
}
if parseErr != nil {
return nil, parseErr
}
return out, nil
}
// parseLsChildren reads line-delimited JSON from r and returns nodes
// whose Path is a direct child of parent. Exposed for testing.
func parseLsChildren(r io.Reader, parent string) ([]LsEntry, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var out []LsEntry
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var entry LsEntry
if err := json.Unmarshal(line, &entry); err != nil {
return nil, fmt.Errorf("restic ls: parse line: %w", err)
}
// Skip the snapshot preamble and any future struct_type
// entries we don't care about.
if entry.Struct == "snapshot" || entry.Path == "" {
continue
}
if isDirectChild(entry.Path, parent) {
out = append(out, entry)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("restic ls: read output: %w", err)
}
return out, nil
}
// normalizeTreePath turns "" / "/" / "/etc/" / "etc" all into a
// canonical absolute form with a leading slash and no trailing slash
// (except the root, which is "/" alone).
func normalizeTreePath(p string) string {
p = strings.TrimSpace(p)
if p == "" || p == "/" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
cleaned := path.Clean(p)
return cleaned
}
// isDirectChild reports whether childPath is a direct child of parent.
// "/etc/nginx" is a direct child of "/etc"; "/etc/nginx/conf" is not.
// "/etc" is a direct child of "/".
func isDirectChild(childPath, parent string) bool {
cp := normalizeTreePath(childPath)
pp := normalizeTreePath(parent)
if pp == "/" {
// Direct children of root: exactly one slash-delimited segment.
return cp != "/" && strings.Count(cp, "/") == 1
}
// Must start with parent + "/" and have no further slashes.
prefix := pp + "/"
if !strings.HasPrefix(cp, prefix) {
return false
}
rest := cp[len(prefix):]
return rest != "" && !strings.Contains(rest, "/")
}