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:
+24
-5
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user