P1-22: snapshot listing via restic snapshots --json

Agent calls restic snapshots --json after each successful backup
(60s timeout, separate from the backup ctx) and ships the projection
over the existing snapshots.report WS envelope. Failure here is
logged but doesn't fail the job — the next successful backup catches
the projection up.

Server-side ReplaceHostSnapshots is delete-then-insert plus a
hosts.snapshot_count update in one transaction so the dashboard's
per-host count stays consistent with the projection. New read
endpoint GET /api/hosts/{id}/snapshots returns the cached list with
a refreshed_at marker so the UI can show staleness when an agent
has been offline.

Schema: dropped the unused snapshots.repo_id FK (repos as a
first-class entity is P2 work), added short_id and refreshed_at
columns, switched the time index to DESC for the most-recent-first
list query. api.Snapshot gains short_id; size_bytes/file_count come
from the embedded summary block on restic 0.16+ and stay zero on
older clients.

Tests cover round-trip, authoritative replacement after forget+prune
shrinkage, and empty-after-wipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 11:20:57 +01:00
parent 811157b4ce
commit 8d5282a180
9 changed files with 514 additions and 10 deletions
+58
View File
@@ -0,0 +1,58 @@
package restic
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"time"
)
// Snapshot mirrors a single entry in `restic snapshots --json`. We
// decode only the fields we project to the server; restic's full
// shape has more (parent, tree, program version) that we don't need.
//
// Summary is only populated by restic 0.16+ (which embeds the backup
// summary inside each snapshot). Older clients leave it nil and the
// agent reports zero size/file-count — the UI degrades to "—".
type Snapshot struct {
ID string `json:"id"`
ShortID string `json:"short_id"`
Time time.Time `json:"time"`
Hostname string `json:"hostname"`
Paths []string `json:"paths"`
Tags []string `json:"tags,omitempty"`
Summary *SnapshotSummary `json:"summary,omitempty"`
}
// SnapshotSummary mirrors the embedded summary block restic 0.16+
// writes into each snapshot record. The naming follows restic's JSON.
type SnapshotSummary struct {
TotalFilesProcessed int64 `json:"total_files_processed"`
TotalBytesProcessed int64 `json:"total_bytes_processed"`
}
// ListSnapshots calls `restic snapshots --json` and returns the
// parsed list. Output is a single JSON array — small even on large
// repos (each entry is ~200 bytes), so we read it all into memory.
func (e Env) ListSnapshots(ctx context.Context) ([]Snapshot, error) {
cmd := exec.CommandContext(ctx, e.Bin, "snapshots", "--json")
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return nil, fmt.Errorf("restic snapshots: exit %d: %s",
ee.ExitCode(), string(ee.Stderr))
}
return nil, fmt.Errorf("restic snapshots: %w", err)
}
var snaps []Snapshot
if err := json.Unmarshal(out, &snaps); err != nil {
return nil, fmt.Errorf("restic snapshots: parse json: %w", err)
}
return snaps, nil
}