109dd853a3
- 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>
92 lines
2.6 KiB
Go
92 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|