v0.6.0: slice 6 Phase B — PBS offsite tier (verify + PBS-API client + reporting)

Spike-proven that backup/restore-to-PBS reuse Phase A unchanged; the only new code is
the verify capability, a small PBS-API client, and PBSSnapshot reporting.

- internal/pbs: fingerprint-pinned, token-authed PBS-API client (Verify/Snapshots/
  TaskStatus, node-from-UPID; secret read from /etc/pve/priv/storage/<id>.pw at runtime,
  never logged) + the verify maintenance loop (own cadence, default 6h, NOT gated/journaled,
  like the watchdog) + SnapshotStore.
- hub: PBSSnapshot filled (namespace/type/id/time/size/owner/protected/encrypted/
  verify_state/verify_upid); PBSReporter collector seam; cross-repo golden + bidirectional
  key-set tests; hub handler parses pbs_snapshots + logs a failed-verify WARN.
- backup: report the ACTUAL vzdump mode (parsed from the task log; PVE may downgrade
  snapshot->stop). proxmox.Storage.Username. config PBSVerifyCadence/secret-dir.
  --selftest=pbs-verify. Backup/restore-to-PBS unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 16:53:04 +02:00
parent faba8e4ff7
commit 766500dfc3
20 changed files with 1082 additions and 37 deletions
+24 -5
View File
@@ -42,6 +42,12 @@ type RestoreTestReporter interface {
RestoreTests(ctx context.Context) []RestoreTest
}
// PBSReporter is the slice-6-Phase-B seam the pbs verify loop plugs into (same pattern).
// Returns the agent's latest-known PBS snapshot inventory + verify-state. nil → empty.
type PBSReporter interface {
PBSSnapshots(ctx context.Context) []PBSSnapshot
}
// Collector builds a HostReport from read-only sources. All deps are behind narrow
// interfaces for unit testing.
type Collector struct {
@@ -50,6 +56,7 @@ type Collector struct {
storage StorageObserver
backups BackupReporter
restoreTests RestoreTestReporter
pbs PBSReporter
hostID string
agentVersion string
logger *slog.Logger
@@ -57,8 +64,8 @@ type Collector struct {
}
// NewCollector builds a collector. hostID echoes config.Hub.HostID; agentVersion is
// the binary version. storage/backups/restoreTests may be nil (their collections emit empty).
func NewCollector(px proxmoxReader, cf CloudflaredProber, storage StorageObserver, backups BackupReporter, restoreTests RestoreTestReporter, hostID, agentVersion string, logger *slog.Logger) *Collector {
// the binary version. storage/backups/restoreTests/pbs may be nil (their collections emit empty).
func NewCollector(px proxmoxReader, cf CloudflaredProber, storage StorageObserver, backups BackupReporter, restoreTests RestoreTestReporter, pbs PBSReporter, hostID, agentVersion string, logger *slog.Logger) *Collector {
if logger == nil {
logger = slog.Default()
}
@@ -68,6 +75,7 @@ func NewCollector(px proxmoxReader, cf CloudflaredProber, storage StorageObserve
storage: storage,
backups: backups,
restoreTests: restoreTests,
pbs: pbs,
hostID: hostID,
agentVersion: agentVersion,
logger: logger,
@@ -96,10 +104,10 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
StorageTargets: c.collectStorage(ctx),
Backups: c.collectBackups(ctx),
RestoreTests: c.collectRestoreTests(ctx),
PBSSnapshots: []PBSSnapshot{}, // Phase B
PBSSnapshots: c.collectPBSSnapshots(ctx),
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)},
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)},
}
return report, nil
}
@@ -198,6 +206,17 @@ func (c *Collector) collectRestoreTests(ctx context.Context) []RestoreTest {
return []RestoreTest{}
}
// collectPBSSnapshots reads the latest PBS snapshot inventory via the seam (nil → empty).
func (c *Collector) collectPBSSnapshots(ctx context.Context) []PBSSnapshot {
if c.pbs == nil {
return []PBSSnapshot{}
}
if s := c.pbs.PBSSnapshots(ctx); s != nil {
return s
}
return []PBSSnapshot{}
}
func (c *Collector) cloudflaredStatus(ctx context.Context) string {
if c.cf == nil {
return "unknown"
+6 -6
View File
@@ -33,7 +33,7 @@ func TestCollect_StorageTargetsFromObserver(t *testing.T) {
obs := fakeObserver{targets: []StorageTarget{
{Name: "local-lvm", Type: StorageTypeLVMThin, State: StorageStateAttached, Reachable: true},
}}
c := NewCollector(px, fakeProber{status: "active"}, obs, nil, nil, "h", "0.5.0", quietLogger())
c := NewCollector(px, fakeProber{status: "active"}, obs, nil, nil, nil, "h", "0.5.0", quietLogger())
r, err := c.Collect(context.Background())
if err != nil {
t.Fatalf("Collect: %v", err)
@@ -45,7 +45,7 @@ func TestCollect_StorageTargetsFromObserver(t *testing.T) {
func TestCollect_StorageObserverErrorDegradesToEmpty(t *testing.T) {
px := &fakePx{node: "n", ns: newTestNodeStatus()}
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{err: errors.New("proxmox down")}, nil, nil, "h", "0.5.0", quietLogger())
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{err: errors.New("proxmox down")}, nil, nil, nil, "h", "0.5.0", quietLogger())
r, err := c.Collect(context.Background())
if err != nil {
t.Fatalf("a storage observe error must not sink the heartbeat: %v", err)
@@ -64,7 +64,7 @@ func TestCollect_HostAndGuests(t *testing.T) {
},
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2, Memory: 2048}},
}
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, "demo-host-01", "0.3.0", quietLogger())
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "demo-host-01", "0.3.0", quietLogger())
r, err := c.Collect(context.Background())
if err != nil {
t.Fatalf("Collect: %v", err)
@@ -104,7 +104,7 @@ func TestCollect_GuestConfigFailureKeepsStatusOmitsSpec(t *testing.T) {
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2}},
cfgErr: map[int]error{200: errors.New("config read failed")},
}
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, "h", "0.3.1", quietLogger())
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.3.1", quietLogger())
r, err := c.Collect(context.Background())
if err != nil {
t.Fatalf("a per-guest failure must NOT fail the whole report: %v", err)
@@ -125,7 +125,7 @@ func TestCollect_GuestConfigFailureKeepsStatusOmitsSpec(t *testing.T) {
func TestCollect_NodeStatusFailureIsHardError(t *testing.T) {
px := &fakePx{node: "n", nsErr: errors.New("proxmox down")}
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, "h", "0.3.0", quietLogger())
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.3.0", quietLogger())
if _, err := c.Collect(context.Background()); err == nil {
t.Fatal("NodeStatus failure must be a hard error (no useful report)")
}
@@ -133,7 +133,7 @@ func TestCollect_NodeStatusFailureIsHardError(t *testing.T) {
func TestCollect_CloudflaredProbeErrorIsUnknown(t *testing.T) {
px := &fakePx{node: "n", ns: newTestNodeStatus()}
c := NewCollector(px, fakeProber{err: errors.New("no systemctl")}, nil, nil, nil, "h", "0.3.0", quietLogger())
c := NewCollector(px, fakeProber{err: errors.New("no systemctl")}, nil, nil, nil, nil, "h", "0.3.0", quietLogger())
r, err := c.Collect(context.Background())
if err != nil {
t.Fatalf("cloudflared failure must not be fatal: %v", err)
+11 -2
View File
@@ -59,8 +59,15 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) {
Pass: true, Verified: "boot+running", TestedAt: "2026-06-09T11:05:00Z", DurationSeconds: 1,
},
},
PBSSnapshots: []PBSSnapshot{}, AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: "active"},
PBSSnapshots: []PBSSnapshot{
{
Namespace: "root", BackupType: "ct", BackupID: "9001", BackupTime: "2026-06-09T14:18:33Z",
SizeBytes: 1, Owner: "felhom@pbs!n100", Protected: false, Encrypted: true,
VerifyState: "ok", VerifyUPID: "UPID:dooplex:x:verify:felhom-spike:u:",
},
},
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: "active"},
}
b, _ := json.Marshal(report)
var got map[string]any
@@ -82,6 +89,8 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) {
// slice-6 additions — backups[0] / restore_tests[0] key sets (the bidirectional guard).
assertSameKeys(t, "backups[0]", firstElem(golden["backups"]), firstElem(got["backups"]))
assertSameKeys(t, "restore_tests[0]", firstElem(golden["restore_tests"]), firstElem(got["restore_tests"]))
// slice-6-Phase-B addition — pbs_snapshots[0] key set.
assertSameKeys(t, "pbs_snapshots[0]", firstElem(golden["pbs_snapshots"]), firstElem(got["pbs_snapshots"]))
}
// field extracts a nested object value from a decoded JSON map (nil if absent/not a map).
+26 -8
View File
@@ -172,15 +172,15 @@ const (
// (the bulk-backup mechanism is slice 10). Cross-repo contract: keep byte-identical with
// felhom.eu/hub and the bidirectional golden key-set test.
type Backup struct {
TargetID string `json:"target_id"` // backup storage name (e.g. "local")
VMID int `json:"vmid"` // source guest
Archive string `json:"archive"` // produced vzdump volid
Mode string `json:"mode"` // snapshot | stop
CrashConsistent bool `json:"crash_consistent"` // always true this slice
TargetID string `json:"target_id"` // backup storage name (e.g. "local")
VMID int `json:"vmid"` // source guest
Archive string `json:"archive"` // produced vzdump volid
Mode string `json:"mode"` // snapshot | stop
CrashConsistent bool `json:"crash_consistent"` // always true this slice
SizeBytes int64 `json:"size_bytes"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
StartedAt string `json:"started_at"` // RFC3339
StartedAt string `json:"started_at"` // RFC3339
DurationSeconds float64 `json:"duration_seconds"`
UncoveredVolumes []string `json:"uncovered_volumes"` // backup=0/unset mountpoints (bulk gap)
}
@@ -198,8 +198,26 @@ type RestoreTest struct {
DurationSeconds float64 `json:"duration_seconds"`
}
type PBSSnapshot struct{} // slice 6 Phase B: PBS snapshot inventory fields TBD
type AuditEntry struct{} // audit-log tail entry fields TBD
// PBSSnapshot is one PBS (offsite) snapshot's inventory + integrity state (doc 03 §8, slice
// 6 Phase B). Sourced from the PBS API (internal/pbs). `verify_state` is the load-bearing
// field — "none" until a verify runs, then "ok"/"failed" (a failed verify is the loudest
// offsite-DR signal). `encrypted` is derived from the snapshot's data crypt-mode
// (zero-knowledge: the PBS server can't read it). Cross-repo contract — byte-identical golden
// + bidirectional key-set test, the slice-5/6 pattern.
type PBSSnapshot struct {
Namespace string `json:"namespace"` // "root" = default ns
BackupType string `json:"backup_type"` // ct | vm
BackupID string `json:"backup_id"`
BackupTime string `json:"backup_time"` // RFC3339
SizeBytes int64 `json:"size_bytes"`
Owner string `json:"owner"`
Protected bool `json:"protected"`
Encrypted bool `json:"encrypted"`
VerifyState string `json:"verify_state"` // ok | failed | none
VerifyUPID string `json:"verify_upid,omitempty"`
}
type AuditEntry struct{} // audit-log tail entry fields TBD
// ControlEnvelope is the hub's 200 response to a host-report. This slice the agent
// adopts ONLY PollIntervalSeconds; the rest are reserved/forward-compat fields it
+14 -1
View File
@@ -111,7 +111,20 @@
"duration_seconds": 38.2
}
],
"pbs_snapshots": [],
"pbs_snapshots": [
{
"namespace": "root",
"backup_type": "ct",
"backup_id": "9001",
"backup_time": "2026-06-09T14:18:33Z",
"size_bytes": 2518889256,
"owner": "felhom@pbs!n100",
"protected": false,
"encrypted": true,
"verify_state": "ok",
"verify_upid": "UPID:dooplex:00034582:5269BDD7:00000005:6A282176:verify:felhom-spike:felhom@pbs!n100:"
}
],
"cloudflared": { "status": "active" },
"audit_tail": []
}