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) } } // ---- Deploy history recording ---------------------------------------------- // seedDispatchWorkload inserts a real workloads row so deploy_history's FK // (workload_id REFERENCES workloads) is satisfied, then returns a plugin // workload pointing at the fake source. func seedDispatchWorkload(t *testing.T, d *Deployer) plugin.Workload { t.Helper() row, err := d.store.CreateWorkload(store.Workload{Kind: "project", RefID: "dh", Name: "dh"}) if err != nil { t.Fatalf("CreateWorkload: %v", err) } return plugin.Workload{ID: row.ID, Name: "dh", SourceKind: "dispatchertest"} } func TestDispatchPlugin_RecordsSuccessHistory(t *testing.T) { resetFake(t) d := newTestDeployer(t) w := seedDispatchWorkload(t, d) intent := plugin.DeploymentIntent{Reason: "manual", Reference: "v9", TriggeredBy: "alice", Metadata: map[string]string{"note": "ship it"}} if err := d.DispatchPlugin(context.Background(), w, intent); err != nil { t.Fatalf("DispatchPlugin: %v", err) } rows, err := d.store.ListDeployHistory(w.ID, 10, 0) if err != nil { t.Fatalf("ListDeployHistory: %v", err) } if len(rows) != 1 { t.Fatalf("expected 1 history row, got %d", len(rows)) } got := rows[0] if got.Outcome != "success" || got.Reason != "manual" || got.Reference != "v9" { t.Fatalf("unexpected row: %+v", got) } if got.TriggeredBy != "alice" || got.Note != "ship it" { t.Fatalf("intent fields not recorded: %+v", got) } if got.Error != "" { t.Fatalf("success row must have empty error, got %q", got.Error) } } func TestDispatchPlugin_RecordsFailureWithoutLeakingError(t *testing.T) { resetFake(t) d := newTestDeployer(t) w := seedDispatchWorkload(t, d) // A deploy error carrying a "secret" must never reach the persisted row. dispatchTestSource.setDeployErr(errors.New("compose up failed (output: SUPER_SECRET=hunter2)")) _ = d.DispatchPlugin(context.Background(), w, plugin.DeploymentIntent{Reason: "manual"}) rows, _ := d.store.ListDeployHistory(w.ID, 10, 0) if len(rows) != 1 { t.Fatalf("expected 1 history row, got %d", len(rows)) } if rows[0].Outcome != "failure" { t.Fatalf("expected failure outcome, got %q", rows[0].Outcome) } if strings.Contains(rows[0].Error, "hunter2") || strings.Contains(rows[0].Error, "SECRET") { t.Fatalf("raw error leaked into history: %q", rows[0].Error) } } func TestDispatchReconcile_RecordsNoHistory(t *testing.T) { resetFake(t) d := newTestDeployer(t) w := seedDispatchWorkload(t, d) if err := d.DispatchReconcile(context.Background(), w); err != nil { t.Fatalf("DispatchReconcile: %v", err) } rows, _ := d.store.ListDeployHistory(w.ID, 10, 0) if len(rows) != 0 { t.Fatalf("reconcile must not write history, got %d rows", len(rows)) } } // ---- 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") } }