e3c7b13d58
Build / build (push) Successful in 10m36s
Closes the workload-first refactor by landing the Priority 3 polish items and the Priority 4 test gap. Net: ~2,400 lines added, ~350 lines modified across 13 files. Priority 3 — polish - apps.* i18n namespace: 276 new keys across apps.list.* (27), apps.new.* (91, sibling of existing apps.new.triggers.*), and apps.detail.* (158, sibling of existing apps.detail.bindings.*). EN+RU at 1314 keys each, perfectly in sync. /apps, /apps/new, /apps/[id] now render entirely from i18n. - New codemap docs/CODEMAPS/workload-plugin.md (238 lines): Source × Trigger contract, dispatch seam, webhook fan-out path, recipes for adding a new Source or Trigger kind. Plus docs/CODEMAPS/INDEX.md gateway. Priority 4 — tests - internal/api/workloads_test.go (new, ~30 subtests): /api/workloads CRUD + deploy + delete + env + volumes + chain + promote-from + triggers list/inline-bind + auth gating + standalone /api/triggers CRUD (create / dup-409 / kind filter / delete). Uses real POST handlers via httptest.NewServer + a fake plugin source registered under "testfakesource". - internal/deployer/dispatch_test.go (new, 11 tests): DispatchPlugin / DispatchTeardown / DispatchReconcile happy + unknown-kind + propagated-error each; PluginDeps wiring; a real 2s-bounded RWMutex deadlock probe on PluginDeps vs SetDNSProvider. - internal/workload/plugin/source/compose/compose_test.go (new, ~26 subtests): composeProjectName sanitization, writeYAML / writeYAMLIfChanged hash short-circuit, Validate happy + bad inputs, Kind / SchemaSample. Coverage delta on the workload-plugin path: - internal/api: 1.1% → 16.0% - internal/deployer: 0% → 54.1% - internal/workload/plugin/source/compose: 0% → 38.5% - Trigger plugins already at 87-95% from the trigger-split work. Production fix surfaced by the tests - store.CreateWorkload now self-references RefID = ID when caller leaves RefID empty (the typical plugin-native path). The api layer's broken backfill loop (called UpdateWorkload, which deliberately omits ref_id) is gone. Multiple sibling plugin workloads can now coexist under the UNIQUE(kind, ref_id) constraint. Review fixes addressed before commit - CRITICAL: deadlock-detect test gained a real 2s time.After (was selecting on context.Background().Done() which never fires). - HIGH: happy-path test now hard-asserts RefID = ID (was a t.Logf that would silently pass after a production fix). - HIGH: standalone /api/triggers CRUD coverage added (was bypassed by the workload-side bind flow). - HIGH: seedWorkload bypass deleted; tests now go through the real POST /api/workloads handler. - MEDIUM: withTempDir restore is a no-op (t.Setenv auto-restores); dead `old := os.Getenv(...)` capture removed. - MEDIUM: list-workloads test now asserts ID membership, not just count. Doc - WORKLOAD_REFACTOR_TODO: all three Priority 1 items, Priority 3 polish, and Priority 4 tests marked DONE. The workload-first arc is closed.
298 lines
9.0 KiB
Go
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")
|
|
}
|
|
}
|