From b1b0d9d1e96777d21b6432b118c0ff91098a5eed Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 22:33:12 +0100 Subject: [PATCH] api: stats partial-update payload + ConfigUpdate.Slot + CommandRun.RequiresAdminCreds Reshape RepoStatsPayload into pointer-field partial-update form matching store.HostRepoStats semantics; add Slot discriminator to ConfigUpdatePayload for admin vs repo credential routing; add RequiresAdminCreds flag to CommandRunPayload for prune/unlock jobs that need delete authority. --- internal/api/messages.go | 52 ++++++++++++++++++-------- internal/api/wire_test.go | 79 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/internal/api/messages.go b/internal/api/messages.go index c93cad6..dd443da 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -90,14 +90,20 @@ const ( // // Args is preserved as a generic free-form slice for kinds that don't // fit the structured fields (e.g. unlock takes none; init takes none). +// +// RequiresAdminCreds tells the agent to load the admin slot of its +// secrets store rather than the everyday repo slot. Set by the server +// only for prune and operator-triggered unlock (kinds that need delete +// authority on a rest-server repo). type CommandRunPayload struct { - JobID string `json:"job_id"` - Kind JobKind `json:"kind"` - Args []string `json:"args,omitempty"` - Includes []string `json:"includes,omitempty"` - Excludes []string `json:"excludes,omitempty"` - Tag string `json:"tag,omitempty"` - RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"` + JobID string `json:"job_id"` + Kind JobKind `json:"kind"` + Args []string `json:"args,omitempty"` + Includes []string `json:"includes,omitempty"` + Excludes []string `json:"excludes,omitempty"` + Tag string `json:"tag,omitempty"` + RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"` + RequiresAdminCreds bool `json:"requires_admin_creds,omitempty"` } // CommandCancelPayload is the server → agent cancel signal. @@ -186,15 +192,24 @@ type Snapshot struct { FileCount int64 `json:"file_count,omitempty"` } -// RepoStatsPayload — agent reports periodic repo health facts derived -// from `restic stats` and lock-file inspection. +// RepoStatsPayload carries a partial-update snapshot of repo health +// facts, shipped by the agent after prune/check/unlock or a periodic +// stats refresh. Pointer fields follow omitempty semantics: a nil +// pointer means "no update for this field" and is omitted on the +// wire; the server merges only the non-nil fields into its +// host_repo_stats row (matching UpsertHostRepoStats partial-update +// semantics). Non-pointer fields (LastCheckStatus) use the empty +// string as the "no update" sentinel. type RepoStatsPayload struct { - SizeBytes int64 `json:"size_bytes"` - SnapshotCount int `json:"snapshot_count"` - DedupRatio float64 `json:"dedup_ratio"` - LastCheckAt time.Time `json:"last_check_at,omitempty"` - LastCheckStatus string `json:"last_check_status,omitempty"` - LockState string `json:"lock_state"` // locked|unlocked + TotalSizeBytes *int64 `json:"total_size_bytes,omitempty"` + RawSizeBytes *int64 `json:"raw_size_bytes,omitempty"` + UniqueFiles *int64 `json:"unique_files,omitempty"` + SnapshotCount *int64 `json:"snapshot_count,omitempty"` + LastCheckAt *time.Time `json:"last_check_at,omitempty"` + LastCheckStatus string `json:"last_check_status,omitempty"` + LockPresent *bool `json:"lock_present,omitempty"` + LastPruneAt *time.Time `json:"last_prune_at,omitempty"` + LastPruneFreedBytes *int64 `json:"last_prune_freed_bytes,omitempty"` } // Schedule is the agent-facing view of a slim Schedule row plus its @@ -252,12 +267,19 @@ type ScheduleFirePayload struct { // ConfigUpdatePayload — server pushes per-host config (currently just // repo connection details). Empty fields mean "leave existing alone"; // to clear something, send an explicit zero value. +// +// Slot picks which secrets-store slot the agent writes the creds to. +// Empty / "repo" = everyday repo creds (default). "admin" = the +// prune-capable admin user (separate slot — not loaded for backups). +// Forwards-compatible: an agent that ignores Slot simply writes to the +// repo slot and admin pushes become no-ops. type ConfigUpdatePayload struct { RepoURL string `json:"repo_url,omitempty"` RepoPassword string `json:"repo_password,omitempty"` // sensitive RepoUsername string `json:"repo_username,omitempty"` RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth) HookShell string `json:"hook_shell,omitempty"` + Slot string `json:"slot,omitempty"` } // AgentUpdateAvailablePayload — informational only; the agent does diff --git a/internal/api/wire_test.go b/internal/api/wire_test.go index 095f2c6..c0b4b96 100644 --- a/internal/api/wire_test.go +++ b/internal/api/wire_test.go @@ -138,6 +138,85 @@ func TestJobProgressShapeStable(t *testing.T) { } } +func TestRepoStatsPayloadRoundTrip(t *testing.T) { + t.Parallel() + + // Nil pointer fields must be omitted from JSON output. + empty := RepoStatsPayload{} + raw, err := json.Marshal(empty) + if err != nil { + t.Fatalf("marshal empty: %v", err) + } + if string(raw) != "{}" { + t.Errorf("empty payload should marshal to {}, got %s", raw) + } + + // Populated fields must survive a round trip. + total := int64(123456) + rawSize := int64(200000) + files := int64(42) + snaps := int64(7) + lockPresent := true + now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + pruneAt := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC) + freed := int64(8192) + + p := RepoStatsPayload{ + TotalSizeBytes: &total, + RawSizeBytes: &rawSize, + UniqueFiles: &files, + SnapshotCount: &snaps, + LastCheckAt: &now, + LastCheckStatus: "ok", + LockPresent: &lockPresent, + LastPruneAt: &pruneAt, + LastPruneFreedBytes: &freed, + } + raw2, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal full: %v", err) + } + var got RepoStatsPayload + if err := json.Unmarshal(raw2, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.TotalSizeBytes == nil || *got.TotalSizeBytes != total { + t.Errorf("TotalSizeBytes: got %v, want %d", got.TotalSizeBytes, total) + } + if got.RawSizeBytes == nil || *got.RawSizeBytes != rawSize { + t.Errorf("RawSizeBytes: got %v, want %d", got.RawSizeBytes, rawSize) + } + if got.UniqueFiles == nil || *got.UniqueFiles != files { + t.Errorf("UniqueFiles: got %v, want %d", got.UniqueFiles, files) + } + if got.SnapshotCount == nil || *got.SnapshotCount != snaps { + t.Errorf("SnapshotCount: got %v, want %d", got.SnapshotCount, snaps) + } + if got.LastCheckAt == nil || !got.LastCheckAt.Equal(now) { + t.Errorf("LastCheckAt: got %v, want %v", got.LastCheckAt, now) + } + if got.LastCheckStatus != "ok" { + t.Errorf("LastCheckStatus: got %q, want %q", got.LastCheckStatus, "ok") + } + if got.LockPresent == nil || *got.LockPresent != lockPresent { + t.Errorf("LockPresent: got %v, want %v", got.LockPresent, lockPresent) + } + if got.LastPruneAt == nil || !got.LastPruneAt.Equal(pruneAt) { + t.Errorf("LastPruneAt: got %v, want %v", got.LastPruneAt, pruneAt) + } + if got.LastPruneFreedBytes == nil || *got.LastPruneFreedBytes != freed { + t.Errorf("LastPruneFreedBytes: got %v, want %d", got.LastPruneFreedBytes, freed) + } + + // Partial update: only set LockPresent. + lockFalse := false + partial := RepoStatsPayload{LockPresent: &lockFalse} + rawPartial, _ := json.Marshal(partial) + if string(rawPartial) != `{"lock_present":false}` { + t.Errorf("partial marshal: got %s, want {\"lock_present\":false}", rawPartial) + } +} + // touch time so the import is used by other tests in this file when // they grow over time. var _ = time.Now