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.
This commit is contained in:
+37
-15
@@ -90,14 +90,20 @@ const (
|
|||||||
//
|
//
|
||||||
// Args is preserved as a generic free-form slice for kinds that don't
|
// 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).
|
// 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 {
|
type CommandRunPayload struct {
|
||||||
JobID string `json:"job_id"`
|
JobID string `json:"job_id"`
|
||||||
Kind JobKind `json:"kind"`
|
Kind JobKind `json:"kind"`
|
||||||
Args []string `json:"args,omitempty"`
|
Args []string `json:"args,omitempty"`
|
||||||
Includes []string `json:"includes,omitempty"`
|
Includes []string `json:"includes,omitempty"`
|
||||||
Excludes []string `json:"excludes,omitempty"`
|
Excludes []string `json:"excludes,omitempty"`
|
||||||
Tag string `json:"tag,omitempty"`
|
Tag string `json:"tag,omitempty"`
|
||||||
RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"`
|
RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"`
|
||||||
|
RequiresAdminCreds bool `json:"requires_admin_creds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommandCancelPayload is the server → agent cancel signal.
|
// CommandCancelPayload is the server → agent cancel signal.
|
||||||
@@ -186,15 +192,24 @@ type Snapshot struct {
|
|||||||
FileCount int64 `json:"file_count,omitempty"`
|
FileCount int64 `json:"file_count,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepoStatsPayload — agent reports periodic repo health facts derived
|
// RepoStatsPayload carries a partial-update snapshot of repo health
|
||||||
// from `restic stats` and lock-file inspection.
|
// 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 {
|
type RepoStatsPayload struct {
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
TotalSizeBytes *int64 `json:"total_size_bytes,omitempty"`
|
||||||
SnapshotCount int `json:"snapshot_count"`
|
RawSizeBytes *int64 `json:"raw_size_bytes,omitempty"`
|
||||||
DedupRatio float64 `json:"dedup_ratio"`
|
UniqueFiles *int64 `json:"unique_files,omitempty"`
|
||||||
LastCheckAt time.Time `json:"last_check_at,omitempty"`
|
SnapshotCount *int64 `json:"snapshot_count,omitempty"`
|
||||||
LastCheckStatus string `json:"last_check_status,omitempty"`
|
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||||
LockState string `json:"lock_state"` // locked|unlocked
|
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
|
// 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
|
// ConfigUpdatePayload — server pushes per-host config (currently just
|
||||||
// repo connection details). Empty fields mean "leave existing alone";
|
// repo connection details). Empty fields mean "leave existing alone";
|
||||||
// to clear something, send an explicit zero value.
|
// 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 {
|
type ConfigUpdatePayload struct {
|
||||||
RepoURL string `json:"repo_url,omitempty"`
|
RepoURL string `json:"repo_url,omitempty"`
|
||||||
RepoPassword string `json:"repo_password,omitempty"` // sensitive
|
RepoPassword string `json:"repo_password,omitempty"` // sensitive
|
||||||
RepoUsername string `json:"repo_username,omitempty"`
|
RepoUsername string `json:"repo_username,omitempty"`
|
||||||
RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth)
|
RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth)
|
||||||
HookShell string `json:"hook_shell,omitempty"`
|
HookShell string `json:"hook_shell,omitempty"`
|
||||||
|
Slot string `json:"slot,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentUpdateAvailablePayload — informational only; the agent does
|
// AgentUpdateAvailablePayload — informational only; the agent does
|
||||||
|
|||||||
@@ -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
|
// touch time so the import is used by other tests in this file when
|
||||||
// they grow over time.
|
// they grow over time.
|
||||||
var _ = time.Now
|
var _ = time.Now
|
||||||
|
|||||||
Reference in New Issue
Block a user