v0.28.0: backup re-target → felhom-pbs (offsite DR) + operator-signed decommission
- BackupConfig.BackupTarget() defaults whole-guest backup to felhom-pbs (separate hardware = real DR), configurable via backup.local_backup_target; all NewBackupRunner sites route through it. PBS round-trip proven live (snapshot marker + restore-test + pct-restore) before the re-point. - signedjobs DecommissionExecutor + ExecutorChain: makes IntentDecommissioned reachable ONLY via a verified operator signature (keyed by the watchdog's storage durable-id); felhom-opsign builds decommission params from -durable-id. Runner wiring moved below the intent-store open. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package signedjobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeDecommissioner struct {
|
||||
got string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDecommissioner) SetDecommissioned(durableID string) error {
|
||||
f.got = durableID
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestDecommissionExecutor_HappyPath(t *testing.T) {
|
||||
fd := &fakeDecommissioner{}
|
||||
ex := NewDecommissionExecutor(fd, nil)
|
||||
err := ex.Execute(context.Background(), opDecommission, json.RawMessage(`{"durable_id":"uuid:abc-123"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd.got != "uuid:abc-123" {
|
||||
t.Errorf("SetDecommissioned got %q, want uuid:abc-123", fd.got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecommissionExecutor_NotOurOp(t *testing.T) {
|
||||
fd := &fakeDecommissioner{}
|
||||
ex := NewDecommissionExecutor(fd, nil)
|
||||
if err := ex.Execute(context.Background(), "storage_wipe", json.RawMessage(`{}`)); err != ErrNoExecutor {
|
||||
t.Errorf("want ErrNoExecutor for a foreign op, got %v", err)
|
||||
}
|
||||
if fd.got != "" {
|
||||
t.Errorf("SetDecommissioned must not be called for a foreign op (got %q)", fd.got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecommissionExecutor_RefusesUnbound(t *testing.T) {
|
||||
fd := &fakeDecommissioner{}
|
||||
ex := NewDecommissionExecutor(fd, nil)
|
||||
if err := ex.Execute(context.Background(), opDecommission, json.RawMessage(`{"durable_id":""}`)); err == nil {
|
||||
t.Error("want error for an empty durable_id (unbound decommission)")
|
||||
}
|
||||
if fd.got != "" {
|
||||
t.Errorf("SetDecommissioned must not be called for an unbound op (got %q)", fd.got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecommissionExecutor_NoIntentStore(t *testing.T) {
|
||||
ex := NewDecommissionExecutor(nil, nil)
|
||||
if err := ex.Execute(context.Background(), opDecommission, json.RawMessage(`{"durable_id":"uuid:x"}`)); err == nil {
|
||||
t.Error("want a hard error (not silent success) when no intent store is wired")
|
||||
}
|
||||
}
|
||||
|
||||
// chainProbe records whether it was reached and what it returns.
|
||||
type chainProbe struct {
|
||||
owns string
|
||||
called bool
|
||||
ret error
|
||||
}
|
||||
|
||||
func (c *chainProbe) Execute(_ context.Context, op string, _ json.RawMessage) error {
|
||||
c.called = true
|
||||
if op == c.owns {
|
||||
return c.ret
|
||||
}
|
||||
return ErrNoExecutor
|
||||
}
|
||||
|
||||
func TestExecutorChain_DispatchesToOwner(t *testing.T) {
|
||||
a := &chainProbe{owns: "storage_wipe"}
|
||||
b := &chainProbe{owns: "decommission"}
|
||||
chain := ExecutorChain{a, b}
|
||||
|
||||
if err := chain.Execute(context.Background(), "decommission", nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !b.called {
|
||||
t.Error("owner (decommission) executor was not reached")
|
||||
}
|
||||
|
||||
// An op no sub-executor owns → ErrNoExecutor (left queued).
|
||||
if err := chain.Execute(context.Background(), "guest_destroy", nil); err != ErrNoExecutor {
|
||||
t.Errorf("want ErrNoExecutor for an unowned op, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user