Files
tiny-forge/internal/deployer/dispatch_test.go
T
alexei.dolgolyov c2ca6c0b73 fix(deployer): wire pre-deploy backup into the unified dispatch path
auto_backup_before_deploy silently did nothing — MaybeBackupBeforeDeploy's
only caller was the legacy executeDeploy pipeline, removed in the
workload-first cutover. Reconnect it as maybeBackupBeforeDeploy(), invoked
from DispatchPlugin after the source resolves and before it runs, so the
setting fires for every source kind. Fail-open: a nil backuper, a
settings-load error, or a backup failure skips the snapshot without
blocking the deploy. Adds predeploy_backup_test.go asserting the wiring.
2026-06-08 16:13:30 +03:00

298 lines
9.0 KiB
Go

package deployer
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// fakeSource is a stub Source implementation registered exactly once
// (kind="dispatchertest") so each dispatch test can assert exactly which
// lifecycle method ran. Counters and the configured error are atomic /
// mutex-guarded because a future parallel run should not flake.
type fakeSource struct {
kind string
mu sync.Mutex
deployErr error
teardownErr error
reconcileErr error
deployCount atomic.Int32
teardownCount atomic.Int32
reconcileCount atomic.Int32
lastIntent plugin.DeploymentIntent
lastDeps plugin.Deps
}
func (f *fakeSource) Kind() string { return f.kind }
func (f *fakeSource) SchemaSample() any { return struct{}{} }
func (f *fakeSource) Validate(json.RawMessage) error { return nil }
func (f *fakeSource) Deploy(_ context.Context, deps plugin.Deps, _ plugin.Workload, intent plugin.DeploymentIntent) error {
f.deployCount.Add(1)
f.mu.Lock()
f.lastIntent = intent
f.lastDeps = deps
err := f.deployErr
f.mu.Unlock()
return err
}
func (f *fakeSource) Teardown(_ context.Context, deps plugin.Deps, _ plugin.Workload) error {
f.teardownCount.Add(1)
f.mu.Lock()
f.lastDeps = deps
err := f.teardownErr
f.mu.Unlock()
return err
}
func (f *fakeSource) Reconcile(_ context.Context, deps plugin.Deps, _ plugin.Workload) error {
f.reconcileCount.Add(1)
f.mu.Lock()
f.lastDeps = deps
err := f.reconcileErr
f.mu.Unlock()
return err
}
func (f *fakeSource) setDeployErr(err error) { f.mu.Lock(); f.deployErr = err; f.mu.Unlock() }
func (f *fakeSource) setTeardownErr(err error) { f.mu.Lock(); f.teardownErr = err; f.mu.Unlock() }
func (f *fakeSource) setReconcileErr(err error) { f.mu.Lock(); f.reconcileErr = err; f.mu.Unlock() }
// dispatchTestSource is the singleton fake registered into the plugin
// registry. Registration happens exactly once — subsequent calls would
// panic (RegisterSource panics on duplicate kind).
var dispatchTestSource = &fakeSource{kind: "dispatchertest"}
func init() {
plugin.RegisterSource(dispatchTestSource)
}
// resetFake clears counters + queued errors between tests. The Source
// instance is shared (the registry can't be cleared per-test) so reset
// is the seam.
func resetFake(t *testing.T) {
t.Helper()
dispatchTestSource.mu.Lock()
dispatchTestSource.deployErr = nil
dispatchTestSource.teardownErr = nil
dispatchTestSource.reconcileErr = nil
dispatchTestSource.lastIntent = plugin.DeploymentIntent{}
dispatchTestSource.lastDeps = plugin.Deps{}
dispatchTestSource.mu.Unlock()
dispatchTestSource.deployCount.Store(0)
dispatchTestSource.teardownCount.Store(0)
dispatchTestSource.reconcileCount.Store(0)
}
func newTestDeployer(t *testing.T) *Deployer {
t.Helper()
st, err := store.New(":memory:")
if err != nil {
t.Fatalf("create store: %v", err)
}
t.Cleanup(func() { st.Close() })
// All other deps are nil — the fake source ignores them. The dispatch
// surface itself does not dereference them.
return New(nil, nil, st, nil, nil, nil, [32]byte{})
}
func sampleWorkload() plugin.Workload {
return plugin.Workload{
ID: "wid-dispatch",
Name: "wkl",
SourceKind: "dispatchertest",
SourceConfig: json.RawMessage(`{}`),
}
}
// ---- DispatchPlugin ---------------------------------------------------------
func TestDispatchPlugin_HappyPath_CallsDeployOnce(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
intent := plugin.DeploymentIntent{Reason: "manual", TriggeredBy: "alice"}
if err := d.DispatchPlugin(context.Background(), sampleWorkload(), intent); err != nil {
t.Fatalf("DispatchPlugin: %v", err)
}
if got := dispatchTestSource.deployCount.Load(); got != 1 {
t.Fatalf("Deploy called %d times, want 1", got)
}
if dispatchTestSource.lastIntent.Reason != "manual" {
t.Fatalf("intent.Reason = %q, want manual", dispatchTestSource.lastIntent.Reason)
}
if dispatchTestSource.lastIntent.TriggeredBy != "alice" {
t.Fatalf("intent.TriggeredBy = %q, want alice", dispatchTestSource.lastIntent.TriggeredBy)
}
}
func TestDispatchPlugin_UnknownKind_ReturnsRegistryError(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
w := sampleWorkload()
w.SourceKind = "no-such-kind"
err := d.DispatchPlugin(context.Background(), w, plugin.DeploymentIntent{})
if err == nil {
t.Fatalf("expected error for unknown kind, got nil")
}
if !strings.Contains(err.Error(), "no source registered") {
t.Fatalf("error = %q, want substring 'no source registered'", err.Error())
}
if got := dispatchTestSource.deployCount.Load(); got != 0 {
t.Fatalf("Deploy must not be called for unknown kind, got %d", got)
}
}
func TestDispatchPlugin_PropagatesSourceError(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
want := errors.New("boom")
dispatchTestSource.setDeployErr(want)
err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{})
if !errors.Is(err, want) {
t.Fatalf("expected wrapped error to match %v, got %v", want, err)
}
}
// ---- DispatchTeardown -------------------------------------------------------
func TestDispatchTeardown_HappyPath_CallsTeardownOnce(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
if err := d.DispatchTeardown(context.Background(), sampleWorkload()); err != nil {
t.Fatalf("DispatchTeardown: %v", err)
}
if got := dispatchTestSource.teardownCount.Load(); got != 1 {
t.Fatalf("Teardown called %d times, want 1", got)
}
if got := dispatchTestSource.deployCount.Load(); got != 0 {
t.Fatalf("Teardown must not call Deploy, got %d Deploy calls", got)
}
}
func TestDispatchTeardown_UnknownKind_ReturnsRegistryError(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
w := sampleWorkload()
w.SourceKind = "no-such-kind"
err := d.DispatchTeardown(context.Background(), w)
if err == nil || !strings.Contains(err.Error(), "no source registered") {
t.Fatalf("expected unknown-kind error, got %v", err)
}
}
func TestDispatchTeardown_PropagatesSourceError(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
want := errors.New("teardown failed")
dispatchTestSource.setTeardownErr(want)
err := d.DispatchTeardown(context.Background(), sampleWorkload())
if !errors.Is(err, want) {
t.Fatalf("expected wrapped error to match %v, got %v", want, err)
}
}
// ---- DispatchReconcile ------------------------------------------------------
func TestDispatchReconcile_HappyPath_CallsReconcileOnce(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
if err := d.DispatchReconcile(context.Background(), sampleWorkload()); err != nil {
t.Fatalf("DispatchReconcile: %v", err)
}
if got := dispatchTestSource.reconcileCount.Load(); got != 1 {
t.Fatalf("Reconcile called %d times, want 1", got)
}
}
func TestDispatchReconcile_UnknownKind_ReturnsRegistryError(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
w := sampleWorkload()
w.SourceKind = "no-such-kind"
err := d.DispatchReconcile(context.Background(), w)
if err == nil || !strings.Contains(err.Error(), "no source registered") {
t.Fatalf("expected unknown-kind error, got %v", err)
}
}
func TestDispatchReconcile_PropagatesSourceError(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
want := errors.New("reconcile failed")
dispatchTestSource.setReconcileErr(want)
err := d.DispatchReconcile(context.Background(), sampleWorkload())
if !errors.Is(err, want) {
t.Fatalf("expected wrapped error to match %v, got %v", want, err)
}
}
// ---- PluginDeps -------------------------------------------------------------
func TestPluginDeps_PassesStoreAndEncKey(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil {
t.Fatalf("dispatch: %v", err)
}
got := dispatchTestSource.lastDeps
if got.Store != d.store {
t.Fatalf("Deps.Store mismatch: got %p want %p", got.Store, d.store)
}
// EncKey is a value type — compare bytes.
if got.EncKey != d.encKey {
t.Fatalf("Deps.EncKey not propagated")
}
}
func TestPluginDeps_DNSReadUnderRWMutex_NoDeadlockOnHotSwap(t *testing.T) {
// PluginDeps takes dnsMu.RLock; SetDNSProvider takes dnsMu.Lock. A bug
// where the read code path also took the write lock would deadlock
// when a concurrent SetDNSProvider runs. Run both in parallel goroutines
// and assert both finish.
d := newTestDeployer(t)
const N = 50
var wg sync.WaitGroup
wg.Add(2 * N)
for i := 0; i < N; i++ {
go func() { defer wg.Done(); _ = d.PluginDeps() }()
go func() { defer wg.Done(); d.SetDNSProvider(nil) }()
}
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
// Real timeout: a deadlock here would hang `go test` for the entire
// package timeout (default 10 min) and report no useful diagnostic.
// Bound at 2s so a regression fails this test specifically.
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("deadlock: PluginDeps/SetDNSProvider did not finish within 2s")
}
}