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:
@@ -160,19 +160,25 @@ CREATE TABLE job_logs (
|
||||
PRIMARY KEY (job_id, seq)
|
||||
);
|
||||
|
||||
-- Snapshot projection — refreshed in full by the agent after each
|
||||
-- backup via the `snapshots.report` WS message. The PRIMARY KEY is
|
||||
-- the restic snapshot id (already content-derived and globally
|
||||
-- unique). repo_id lives in Phase 2 alongside first-class repo
|
||||
-- management; for Phase 1 each host owns a single repo implicitly.
|
||||
CREATE TABLE snapshots (
|
||||
id TEXT PRIMARY KEY, -- restic snapshot id
|
||||
id TEXT PRIMARY KEY, -- restic snapshot id (long form)
|
||||
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
|
||||
short_id TEXT NOT NULL,
|
||||
time TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
paths TEXT NOT NULL DEFAULT '[]',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
file_count INTEGER NOT NULL DEFAULT 0
|
||||
file_count INTEGER NOT NULL DEFAULT 0,
|
||||
refreshed_at TEXT NOT NULL -- when the projection was last synced
|
||||
);
|
||||
CREATE INDEX snapshots_host_id ON snapshots(host_id);
|
||||
CREATE INDEX snapshots_time ON snapshots(time);
|
||||
CREATE INDEX snapshots_time ON snapshots(time DESC);
|
||||
|
||||
CREATE TABLE alerts (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Snapshot mirrors the snapshots projection table.
|
||||
type Snapshot struct {
|
||||
ID string
|
||||
HostID string
|
||||
ShortID string
|
||||
Time time.Time
|
||||
Hostname string
|
||||
Paths []string
|
||||
Tags []string
|
||||
SizeBytes int64
|
||||
FileCount int64
|
||||
RefreshedAt time.Time
|
||||
}
|
||||
|
||||
// ReplaceHostSnapshots atomically replaces the snapshot projection for
|
||||
// one host. Snapshots are reported by the agent in full after each
|
||||
// successful backup, so we treat the message as the new source of
|
||||
// truth and delete-then-insert under one transaction.
|
||||
//
|
||||
// snapshot_count on the host row is updated in the same tx so the
|
||||
// dashboard's per-host count is always consistent with the snapshot
|
||||
// list the host detail page renders.
|
||||
func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps []Snapshot, when time.Time) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: begin snapshots tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM snapshots WHERE host_id = ?`, hostID); err != nil {
|
||||
return fmt.Errorf("store: clear snapshots for host: %w", err)
|
||||
}
|
||||
|
||||
if len(snaps) > 0 {
|
||||
stmt, err := tx.PrepareContext(ctx,
|
||||
`INSERT INTO snapshots (
|
||||
id, host_id, short_id, time, hostname, paths, tags,
|
||||
size_bytes, file_count, refreshed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: prepare snapshot insert: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
refreshed := when.UTC().Format(time.RFC3339Nano)
|
||||
for _, snap := range snaps {
|
||||
paths, _ := json.Marshal(snap.Paths)
|
||||
tags, _ := json.Marshal(snap.Tags)
|
||||
if _, err := stmt.ExecContext(ctx,
|
||||
snap.ID, hostID, snap.ShortID,
|
||||
snap.Time.UTC().Format(time.RFC3339Nano),
|
||||
snap.Hostname, string(paths), string(tags),
|
||||
snap.SizeBytes, snap.FileCount, refreshed,
|
||||
); err != nil {
|
||||
return fmt.Errorf("store: insert snapshot %s: %w", snap.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE hosts SET snapshot_count = ? WHERE id = ?`,
|
||||
len(snaps), hostID); err != nil {
|
||||
return fmt.Errorf("store: update host snapshot_count: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("store: commit snapshots: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSnapshotsByHost returns the cached snapshot list for a host,
|
||||
// most-recent first. Empty slice is a normal "no snapshots yet" case.
|
||||
func (s *Store) ListSnapshotsByHost(ctx context.Context, hostID string) ([]Snapshot, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, host_id, short_id, time, hostname, paths, tags,
|
||||
size_bytes, file_count, refreshed_at
|
||||
FROM snapshots
|
||||
WHERE host_id = ?
|
||||
ORDER BY time DESC`, hostID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list snapshots: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Snapshot
|
||||
for rows.Next() {
|
||||
snap, err := scanSnapshotRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *snap)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanSnapshotRow(r *sql.Rows) (*Snapshot, error) {
|
||||
var (
|
||||
snap Snapshot
|
||||
t, refresh string
|
||||
paths, tags string
|
||||
)
|
||||
if err := r.Scan(&snap.ID, &snap.HostID, &snap.ShortID,
|
||||
&t, &snap.Hostname, &paths, &tags,
|
||||
&snap.SizeBytes, &snap.FileCount, &refresh); err != nil {
|
||||
return nil, fmt.Errorf("store: scan snapshot: %w", err)
|
||||
}
|
||||
snap.Time, _ = time.Parse(time.RFC3339Nano, t)
|
||||
snap.RefreshedAt, _ = time.Parse(time.RFC3339Nano, refresh)
|
||||
if paths != "" {
|
||||
_ = json.Unmarshal([]byte(paths), &snap.Paths)
|
||||
}
|
||||
if tags != "" {
|
||||
_ = json.Unmarshal([]byte(tags), &snap.Tags)
|
||||
}
|
||||
return &snap, nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// makeSnapHost inserts a minimal host row that snapshot tests can hang
|
||||
// off. Returns the host id.
|
||||
func makeSnapHost(t *testing.T, s *Store) string {
|
||||
t.Helper()
|
||||
const id = "01HSNAPHOST00000000000000"
|
||||
if err := s.CreateHost(context.Background(), Host{
|
||||
ID: id, Name: "snap-host", OS: "linux", Arch: "amd64",
|
||||
AgentVersion: "dev", ResticVersion: "0.16.0", ProtocolVersion: 1,
|
||||
EnrolledAt: time.Now().UTC(),
|
||||
}, "tokenhash", ""); err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestReplaceHostSnapshotsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := openTestStore(t)
|
||||
hostID := makeSnapHost(t, s)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
in := []Snapshot{
|
||||
{
|
||||
ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000",
|
||||
ShortID: "deadbeef",
|
||||
Time: now.Add(-2 * time.Hour),
|
||||
Hostname: "snap-host",
|
||||
Paths: []string{"/etc", "/home"},
|
||||
Tags: []string{"daily"},
|
||||
SizeBytes: 4096, FileCount: 12,
|
||||
},
|
||||
{
|
||||
ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000",
|
||||
ShortID: "cafef00d",
|
||||
Time: now.Add(-1 * time.Hour),
|
||||
Hostname: "snap-host",
|
||||
Paths: []string{"/etc"},
|
||||
SizeBytes: 8192, FileCount: 24,
|
||||
},
|
||||
}
|
||||
if err := s.ReplaceHostSnapshots(ctx, hostID, in, now); err != nil {
|
||||
t.Fatalf("replace: %v", err)
|
||||
}
|
||||
|
||||
out, err := s.ListSnapshotsByHost(ctx, hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("want 2 snapshots, got %d", len(out))
|
||||
}
|
||||
// Ordered by time DESC — most recent first.
|
||||
if out[0].ShortID != "cafef00d" {
|
||||
t.Errorf("want most-recent first; got %q", out[0].ShortID)
|
||||
}
|
||||
if got := len(out[0].Paths); got != 1 {
|
||||
t.Errorf("paths roundtrip lost: %v", out[0].Paths)
|
||||
}
|
||||
if out[1].Tags == nil || out[1].Tags[0] != "daily" {
|
||||
t.Errorf("tags roundtrip lost: %v", out[1].Tags)
|
||||
}
|
||||
|
||||
// Host snapshot_count is updated atomically.
|
||||
h, err := s.GetHost(ctx, hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("get host: %v", err)
|
||||
}
|
||||
if h.SnapshotCount != 2 {
|
||||
t.Errorf("host snapshot_count = %d, want 2", h.SnapshotCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHostSnapshotsIsAuthoritative(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := openTestStore(t)
|
||||
hostID := makeSnapHost(t, s)
|
||||
ctx := context.Background()
|
||||
|
||||
mk := func(id, short string, tOff time.Duration) Snapshot {
|
||||
return Snapshot{
|
||||
ID: id, ShortID: short, Time: time.Now().UTC().Add(tOff),
|
||||
Hostname: "snap-host", Paths: []string{"/x"},
|
||||
}
|
||||
}
|
||||
first := []Snapshot{
|
||||
mk("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "aaaaaaaa", -3*time.Hour),
|
||||
mk("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "bbbbbbbb", -2*time.Hour),
|
||||
mk("cccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccc", -1*time.Hour),
|
||||
}
|
||||
if err := s.ReplaceHostSnapshots(ctx, hostID, first, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("replace 1: %v", err)
|
||||
}
|
||||
|
||||
// Subsequent forget+prune on the host: only one snapshot remains.
|
||||
second := []Snapshot{
|
||||
mk("cccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccc", -1*time.Hour),
|
||||
}
|
||||
if err := s.ReplaceHostSnapshots(ctx, hostID, second, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("replace 2: %v", err)
|
||||
}
|
||||
|
||||
out, err := s.ListSnapshotsByHost(ctx, hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].ShortID != "cccccccc" {
|
||||
t.Errorf("after second replace, want [cccccccc], got %+v", out)
|
||||
}
|
||||
h, _ := s.GetHost(ctx, hostID)
|
||||
if h.SnapshotCount != 1 {
|
||||
t.Errorf("snapshot_count should track replacement: %d", h.SnapshotCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHostSnapshotsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := openTestStore(t)
|
||||
hostID := makeSnapHost(t, s)
|
||||
ctx := context.Background()
|
||||
|
||||
// First a non-empty replace.
|
||||
if err := s.ReplaceHostSnapshots(ctx, hostID, []Snapshot{
|
||||
{ID: "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
ShortID: "11111111", Time: time.Now().UTC(), Hostname: "snap-host",
|
||||
Paths: []string{"/x"}},
|
||||
}, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("replace 1: %v", err)
|
||||
}
|
||||
// Then empty — host has been wiped.
|
||||
if err := s.ReplaceHostSnapshots(ctx, hostID, nil, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("replace empty: %v", err)
|
||||
}
|
||||
out, err := s.ListSnapshotsByHost(ctx, hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Errorf("want empty, got %d", len(out))
|
||||
}
|
||||
h, _ := s.GetHost(ctx, hostID)
|
||||
if h.SnapshotCount != 0 {
|
||||
t.Errorf("snapshot_count should reset to 0, got %d", h.SnapshotCount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user