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:
@@ -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, "/")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package restic
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// realistic restic ls --json output sample. First line is the
|
||||
// snapshot preamble, subsequent lines are nodes. Trimmed to a few
|
||||
// entries that exercise depth filtering.
|
||||
const sampleLsOutput = `{"struct_type":"snapshot","time":"2026-05-04T09:14:00Z","id":"f3a7b2c1"}
|
||||
{"name":"etc","type":"dir","path":"/etc","permissions":"drwxr-xr-x","struct_type":"node"}
|
||||
{"name":"nginx","type":"dir","path":"/etc/nginx","permissions":"drwxr-xr-x","struct_type":"node"}
|
||||
{"name":"nginx.conf","type":"file","path":"/etc/nginx/nginx.conf","size":2400,"struct_type":"node"}
|
||||
{"name":"sites-available","type":"dir","path":"/etc/nginx/sites-available","struct_type":"node"}
|
||||
{"name":"alfa.conf","type":"file","path":"/etc/nginx/sites-available/alfa.conf","size":3100,"struct_type":"node"}
|
||||
{"name":"default.conf","type":"file","path":"/etc/nginx/sites-available/default.conf","size":2900,"struct_type":"node"}
|
||||
`
|
||||
|
||||
func TestParseLsChildrenAtRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
entries, err := parseLsChildren(strings.NewReader(sampleLsOutput), "/")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries: got %d (%+v), want 1", len(entries), entries)
|
||||
}
|
||||
if entries[0].Name != "etc" || entries[0].Path != "/etc" || entries[0].Type != "dir" {
|
||||
t.Fatalf("entry: %+v", entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLsChildrenAtEtc(t *testing.T) {
|
||||
t.Parallel()
|
||||
entries, err := parseLsChildren(strings.NewReader(sampleLsOutput), "/etc")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries: got %d, want 1 (just nginx, not nested children)", len(entries))
|
||||
}
|
||||
if entries[0].Name != "nginx" {
|
||||
t.Fatalf("entry: %+v", entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLsChildrenAtNginx(t *testing.T) {
|
||||
t.Parallel()
|
||||
entries, err := parseLsChildren(strings.NewReader(sampleLsOutput), "/etc/nginx")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries: got %d (%+v), want 2 (nginx.conf + sites-available, not nested)",
|
||||
len(entries), entries)
|
||||
}
|
||||
gotNames := []string{entries[0].Name, entries[1].Name}
|
||||
want := map[string]bool{"nginx.conf": true, "sites-available": true}
|
||||
for _, n := range gotNames {
|
||||
if !want[n] {
|
||||
t.Errorf("unexpected name %q in result", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLsChildrenAtSitesAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
entries, err := parseLsChildren(strings.NewReader(sampleLsOutput), "/etc/nginx/sites-available")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries: got %d, want 2", len(entries))
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Type != "file" {
|
||||
t.Errorf("expected file type, got %q on %q", e.Type, e.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTreePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct{ in, want string }{
|
||||
{"", "/"},
|
||||
{"/", "/"},
|
||||
{"/etc", "/etc"},
|
||||
{"/etc/", "/etc"},
|
||||
{"etc/nginx", "/etc/nginx"},
|
||||
{"/etc//nginx", "/etc/nginx"},
|
||||
{"/etc/./nginx", "/etc/nginx"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := normalizeTreePath(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeTreePath(%q): got %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDirectChild(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
child, parent string
|
||||
want bool
|
||||
}{
|
||||
{"/etc", "/", true},
|
||||
{"/etc/nginx", "/", false},
|
||||
{"/etc/nginx", "/etc", true},
|
||||
{"/etc/nginx/conf", "/etc", false},
|
||||
{"/etc/nginx/conf", "/etc/nginx", true},
|
||||
{"/etc", "/etc", false},
|
||||
{"/etcc", "/etc", false}, // prefix match guard
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := isDirectChild(c.child, c.parent)
|
||||
if got != c.want {
|
||||
t.Errorf("isDirectChild(%q, %q): got %v, want %v",
|
||||
c.child, c.parent, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user