Files
admin 109dd853a3 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>
2026-06-12 20:26:34 +02:00

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)
}
}