766500dfc3
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>
101 lines
3.7 KiB
Go
101 lines
3.7 KiB
Go
package pbs
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// verifyServer is a fake PBS that answers verify POSTs, task status, and a snapshot list whose
|
|
// verification.state is controlled by `state` ("" → no verification field → none).
|
|
func verifyServer(t *testing.T, state string) (*httptest.Server, string) {
|
|
t.Helper()
|
|
verification := ""
|
|
if state != "" {
|
|
verification = `"verification":{"state":"` + state + `","upid":"UPID:dooplex:0:0:0:0:verify:ds:u:"},`
|
|
}
|
|
body := `{"data":[{"backup-type":"ct","backup-id":"9001","backup-time":1781014713,"size":10,"owner":"u",` +
|
|
verification + `"files":[{"filename":"root.pxar.didx","crypt-mode":"encrypt","size":10}]}]}`
|
|
return newPBSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.Contains(r.URL.Path, "/verify"):
|
|
w.Write([]byte(`{"data":"UPID:dooplex:0:0:0:0:verify:ds:u:"}`))
|
|
case strings.Contains(r.URL.Path, "/status"):
|
|
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"OK","node":"dooplex"}}`))
|
|
default: // snapshots
|
|
w.Write([]byte(body))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestVerifyLoop_RecordsState(t *testing.T) {
|
|
ts, fp := verifyServer(t, "ok")
|
|
host, port := hostPort(t, ts.URL)
|
|
c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "u@pbs!t", Secret: "s"})
|
|
store := NewSnapshotStore()
|
|
loop := NewVerifyLoop(VerifyLoopOptions{
|
|
Targets: func(context.Context) ([]Target, error) { return []Target{{Datastore: "ds", Client: c}}, nil },
|
|
Store: store,
|
|
})
|
|
loop.RunOnce(context.Background())
|
|
|
|
snaps := store.PBSSnapshots(context.Background())
|
|
if len(snaps) != 1 || snaps[0].VerifyState != VerifyOK {
|
|
t.Fatalf("loop should record 1 ok snapshot, got %+v", snaps)
|
|
}
|
|
}
|
|
|
|
func TestVerifyLoop_FailedVerifyRecorded(t *testing.T) {
|
|
ts, fp := verifyServer(t, "failed")
|
|
host, port := hostPort(t, ts.URL)
|
|
c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "u@pbs!t", Secret: "s"})
|
|
store := NewSnapshotStore()
|
|
NewVerifyLoop(VerifyLoopOptions{
|
|
Targets: func(context.Context) ([]Target, error) { return []Target{{Datastore: "ds", Client: c}}, nil },
|
|
Store: store,
|
|
}).RunOnce(context.Background())
|
|
if s := store.PBSSnapshots(context.Background()); len(s) != 1 || s[0].VerifyState != VerifyFailed {
|
|
t.Fatalf("failed verify must be recorded, got %+v", s)
|
|
}
|
|
}
|
|
|
|
// TestVerifyLoop_NeverLogsSecret runs a full cycle with the loop's logger capturing output and
|
|
// asserts the token secret never appears in any log line.
|
|
func TestVerifyLoop_NeverLogsSecret(t *testing.T) {
|
|
const secret = "tok-secret-DO-NOT-LOG-7f3a"
|
|
ts, fp := verifyServer(t, "ok")
|
|
host, port := hostPort(t, ts.URL)
|
|
c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "felhom@pbs!n100", Secret: secret})
|
|
|
|
var buf bytes.Buffer
|
|
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
loop := NewVerifyLoop(VerifyLoopOptions{
|
|
Targets: func(context.Context) ([]Target, error) { return []Target{{Datastore: "ds", Client: c}}, nil },
|
|
Store: NewSnapshotStore(),
|
|
Logger: logger,
|
|
})
|
|
loop.RunOnce(context.Background())
|
|
if strings.Contains(buf.String(), secret) {
|
|
t.Fatalf("the verify loop logged the token secret")
|
|
}
|
|
}
|
|
|
|
func TestVerifyLoop_DisabledByNegativeCadence(t *testing.T) {
|
|
loop := NewVerifyLoop(VerifyLoopOptions{
|
|
Targets: func(context.Context) ([]Target, error) { return nil, nil },
|
|
Store: NewSnapshotStore(),
|
|
Cadence: -1,
|
|
})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- loop.Run(ctx) }()
|
|
cancel()
|
|
if err := <-done; err != nil {
|
|
t.Fatalf("disabled loop Run should return nil on cancel, got %v", err)
|
|
}
|
|
}
|