package compose import ( "context" "encoding/json" "errors" "strings" "sync" "testing" "github.com/alexei/tinyforge/internal/stack" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/workload/plugin" ) // fakeRunner stands in for *stack.Compose. Every method records its // inputs and returns whatever the test set on the corresponding field. // Defaults are happy-path: empty services from Ps, no error from Up / // Down. Fields are slice-typed so a single fakeRunner can serve a // sequence of calls (Deploy issues Up + Ps in order). type fakeRunner struct { mu sync.Mutex upCalls []runnerCall upOuts []string upErrs []error downCalls []runnerCall downOuts []string downErrs []error psCalls []runnerCall psResults [][]stack.Service psErrs []error upCallIdx int psCallIdx int downCallI int } type runnerCall struct { ProjectName string YAMLPath string RemoveVolumes bool } func (f *fakeRunner) Up(_ context.Context, projectName, yamlPath string) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.upCalls = append(f.upCalls, runnerCall{ProjectName: projectName, YAMLPath: yamlPath}) out, err := pop(f.upOuts, f.upErrs, f.upCallIdx) f.upCallIdx++ return out, err } func (f *fakeRunner) Down(_ context.Context, projectName string, removeVolumes bool) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.downCalls = append(f.downCalls, runnerCall{ProjectName: projectName, RemoveVolumes: removeVolumes}) out, err := pop(f.downOuts, f.downErrs, f.downCallI) f.downCallI++ return out, err } func (f *fakeRunner) Ps(_ context.Context, projectName, yamlPath string) ([]stack.Service, error) { f.mu.Lock() defer f.mu.Unlock() f.psCalls = append(f.psCalls, runnerCall{ProjectName: projectName, YAMLPath: yamlPath}) idx := f.psCallIdx f.psCallIdx++ var svcs []stack.Service if idx < len(f.psResults) { svcs = f.psResults[idx] } var err error if idx < len(f.psErrs) { err = f.psErrs[idx] } return svcs, err } // pop returns the nth element of outs/errs or zero values when n is // past the end. Lets a test set a single expected response without // padding slices for every other call. func pop(outs []string, errs []error, n int) (string, error) { var out string if n < len(outs) { out = outs[n] } var err error if n < len(errs) { err = errs[n] } return out, err } // withFakeRunner swaps newComposeRunner for the duration of one test // and restores the original on cleanup. Tests that need to inspect the // fake post-hoc keep the returned pointer. func withFakeRunner(t *testing.T, f *fakeRunner) { t.Helper() orig := newComposeRunner newComposeRunner = func() composeRunner { return f } t.Cleanup(func() { newComposeRunner = orig }) } func testStore(t *testing.T) *store.Store { t.Helper() st, err := store.New(":memory:") if err != nil { t.Fatalf("open store: %v", err) } t.Cleanup(func() { _ = st.Close() }) return st } // seedWorkload creates the parent workload row that container rows FK // onto. Returns the workload's ID so callers can reuse it. func seedWorkload(t *testing.T, st *store.Store, name, yamlText string) string { t.Helper() cfg := Config{ComposeYAML: yamlText} body, err := json.Marshal(cfg) if err != nil { t.Fatalf("marshal config: %v", err) } w, err := st.CreateWorkload(store.Workload{ Kind: "plugin", Name: name, SourceKind: "compose", SourceConfig: string(body), }) if err != nil { t.Fatalf("create workload: %v", err) } return w.ID } func TestDeploy_HappyPath(t *testing.T) { withTempDir(t) // isolates the YAML scratch dir under t.TempDir() deps := plugin.Deps{Store: testStore(t)} yamlText := "services:\n web:\n image: nginx:alpine\n" wid := seedWorkload(t, deps.Store, "myapp", yamlText) w := plugin.Workload{ ID: wid, Name: "myapp", SourceKind: "compose", SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}), } fake := &fakeRunner{ psResults: [][]stack.Service{{ {Service: "web", State: "running", Status: "Up 5 seconds"}, }}, } withFakeRunner(t, fake) src := &source{} if err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{}); err != nil { t.Fatalf("Deploy: %v", err) } // Up called exactly once with the workload-derived project name. if len(fake.upCalls) != 1 { t.Fatalf("Up called %d times, want 1", len(fake.upCalls)) } if !strings.HasPrefix(fake.upCalls[0].ProjectName, "tf-myapp-") { t.Errorf("Up projectName = %q, want prefix tf-myapp-", fake.upCalls[0].ProjectName) } if !strings.HasSuffix(fake.upCalls[0].YAMLPath, "compose.yml") { t.Errorf("Up yamlPath = %q, want suffix compose.yml", fake.upCalls[0].YAMLPath) } // Ps follows Up to enumerate the resulting containers. if len(fake.psCalls) != 1 { t.Fatalf("Ps called %d times, want 1", len(fake.psCalls)) } // Service row written. row, err := deps.Store.GetContainerByID(wid + ":web") if err != nil { t.Fatalf("get container row: %v", err) } if row.WorkloadID != wid { t.Errorf("row.WorkloadID = %q, want %q", row.WorkloadID, wid) } if row.Role != "web" { t.Errorf("row.Role = %q, want %q", row.Role, "web") } if row.State != "running" { t.Errorf("row.State = %q, want %q", row.State, "running") } } func TestDeploy_EmptyYAMLConfig_RejectsBeforeExec(t *testing.T) { deps := plugin.Deps{Store: testStore(t)} wid := seedWorkload(t, deps.Store, "empty", "services:\n web:\n image: x\n") w := plugin.Workload{ ID: wid, Name: "empty", SourceKind: "compose", SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: ""}), } fake := &fakeRunner{} withFakeRunner(t, fake) src := &source{} err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{}) if err == nil { t.Fatal("Deploy accepted empty compose_yaml") } if !strings.Contains(err.Error(), "empty compose_yaml") { t.Errorf("error = %v, want substring \"empty compose_yaml\"", err) } if len(fake.upCalls) != 0 { t.Errorf("Up should not have been called; got %d calls", len(fake.upCalls)) } } func TestDeploy_UpFailure_PropagatesAndIncludesTruncatedOutput(t *testing.T) { withTempDir(t) deps := plugin.Deps{Store: testStore(t)} yamlText := "services:\n web:\n image: bad-image\n" wid := seedWorkload(t, deps.Store, "fail", yamlText) w := plugin.Workload{ ID: wid, Name: "fail", SourceKind: "compose", SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}), } bigOut := strings.Repeat("docker compose log noise ", 200) // > 1024 bytes fake := &fakeRunner{ upOuts: []string{bigOut}, upErrs: []error{errors.New("exit status 1")}, } withFakeRunner(t, fake) src := &source{} err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{}) if err == nil { t.Fatal("Deploy accepted Up failure") } if !strings.Contains(err.Error(), "docker compose up") { t.Errorf("error = %v, want substring \"docker compose up\"", err) } if !strings.Contains(err.Error(), "exit status 1") { t.Errorf("error = %v, want wrapped Up err", err) } if !strings.Contains(err.Error(), "(truncated)") { t.Errorf("error = %v, want truncated-output marker", err) } // Ps must not be called when Up failed. if len(fake.psCalls) != 0 { t.Errorf("Ps called %d times after Up failure; want 0", len(fake.psCalls)) } } func TestDeploy_UpSucceedsButPsFails_SurfacesError(t *testing.T) { // `up` succeeded but enumerate failed — Deploy must surface so the UI // doesn't show an empty containers index for a running stack. withTempDir(t) deps := plugin.Deps{Store: testStore(t)} yamlText := "services:\n web:\n image: nginx\n" wid := seedWorkload(t, deps.Store, "psfail", yamlText) w := plugin.Workload{ ID: wid, Name: "psfail", SourceKind: "compose", SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}), } fake := &fakeRunner{ psErrs: []error{errors.New("compose ps boom")}, } withFakeRunner(t, fake) src := &source{} err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{}) if err == nil { t.Fatal("Deploy ignored Ps failure") } if !strings.Contains(err.Error(), "sync container rows") { t.Errorf("error = %v, want substring \"sync container rows\"", err) } } func TestTeardown_DropsContainerRows_EvenWhenDownFails(t *testing.T) { // docker compose down failing must not orphan rows in the DB. withTempDir(t) deps := plugin.Deps{Store: testStore(t)} wid := seedWorkload(t, deps.Store, "tdown", "services:\n web:\n image: nginx\n") // Seed two service rows the way Deploy would. for _, role := range []string{"web", "db"} { if err := deps.Store.UpsertContainer(store.Container{ ID: wid + ":" + role, WorkloadID: wid, WorkloadKind: "compose", Role: role, Host: "local", State: "running", }); err != nil { t.Fatalf("seed container: %v", err) } } fake := &fakeRunner{downErrs: []error{errors.New("compose project unknown")}} withFakeRunner(t, fake) src := &source{} w := plugin.Workload{ ID: wid, Name: "tdown", SourceKind: "compose", SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: "services:\n web:\n image: nginx\n"}), } if err := src.Teardown(context.Background(), deps, w); err != nil { t.Fatalf("Teardown: %v", err) } // Down requested removeVolumes=true (matches the docstring claim). if len(fake.downCalls) != 1 { t.Fatalf("Down calls = %d, want 1", len(fake.downCalls)) } if !fake.downCalls[0].RemoveVolumes { t.Errorf("Down removeVolumes = false, want true (workload teardown is destructive)") } // Rows gone despite the Down error. for _, role := range []string{"web", "db"} { if _, err := deps.Store.GetContainerByID(wid + ":" + role); !errors.Is(err, store.ErrNotFound) { t.Errorf("container row %q survived teardown: err=%v", role, err) } } } func TestTeardown_HappyPath(t *testing.T) { withTempDir(t) deps := plugin.Deps{Store: testStore(t)} wid := seedWorkload(t, deps.Store, "tdown2", "services:\n web:\n image: nginx\n") if err := deps.Store.UpsertContainer(store.Container{ ID: wid + ":web", WorkloadID: wid, WorkloadKind: "compose", Role: "web", Host: "local", State: "running", }); err != nil { t.Fatalf("seed: %v", err) } fake := &fakeRunner{} withFakeRunner(t, fake) src := &source{} w := plugin.Workload{ ID: wid, Name: "tdown2", SourceKind: "compose", SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: "services:\n web:\n image: nginx\n"}), } if err := src.Teardown(context.Background(), deps, w); err != nil { t.Fatalf("Teardown: %v", err) } if len(fake.downCalls) != 1 { t.Errorf("Down calls = %d, want 1", len(fake.downCalls)) } if _, err := deps.Store.GetContainerByID(wid + ":web"); !errors.Is(err, store.ErrNotFound) { t.Errorf("container row survived teardown: err=%v", err) } } func TestReconcile_PsSuccess_UpsertsRows(t *testing.T) { withTempDir(t) deps := plugin.Deps{Store: testStore(t)} yamlText := "services:\n web:\n image: nginx\n db:\n image: postgres\n" wid := seedWorkload(t, deps.Store, "rec", yamlText) fake := &fakeRunner{ psResults: [][]stack.Service{{ {Service: "web", State: "running"}, {Service: "db", State: "running"}, }}, } withFakeRunner(t, fake) src := &source{} w := plugin.Workload{ ID: wid, Name: "rec", SourceKind: "compose", SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}), } if err := src.Reconcile(context.Background(), deps, w); err != nil { t.Fatalf("Reconcile: %v", err) } for _, role := range []string{"web", "db"} { row, err := deps.Store.GetContainerByID(wid + ":" + role) if err != nil { t.Errorf("row %q missing after reconcile: %v", role, err) continue } if row.State != "running" { t.Errorf("row %q state = %q, want \"running\"", role, row.State) } } } func TestReconcile_PsFailure_MarksExistingRowsMissing(t *testing.T) { // When compose ps fails (project unknown to Docker), the reconciler // flips existing rows to "missing" rather than deleting them — the UI // surfaces the desync to the operator. withTempDir(t) deps := plugin.Deps{Store: testStore(t)} yamlText := "services:\n web:\n image: nginx\n" wid := seedWorkload(t, deps.Store, "missing", yamlText) if err := deps.Store.UpsertContainer(store.Container{ ID: wid + ":web", WorkloadID: wid, WorkloadKind: "compose", Role: "web", Host: "local", State: "running", }); err != nil { t.Fatalf("seed: %v", err) } fake := &fakeRunner{psErrs: []error{errors.New("no such project")}} withFakeRunner(t, fake) src := &source{} w := plugin.Workload{ ID: wid, Name: "missing", SourceKind: "compose", SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}), } if err := src.Reconcile(context.Background(), deps, w); err != nil { t.Fatalf("Reconcile returned %v; should be nil even on Ps failure", err) } row, err := deps.Store.GetContainerByID(wid + ":web") if err != nil { t.Fatalf("row missing entirely (should be marked, not deleted): %v", err) } if row.State != "missing" { t.Errorf("row.State = %q, want \"missing\"", row.State) } } func TestReconcile_FallsBackToStatusWhenStateEmpty(t *testing.T) { // Some compose versions populate Status (human string) but not State // (enum) for non-running services. upsertServiceRow falls back to // Status; verify that here. withTempDir(t) deps := plugin.Deps{Store: testStore(t)} yamlText := "services:\n worker:\n image: alpine\n" wid := seedWorkload(t, deps.Store, "fallback", yamlText) fake := &fakeRunner{ psResults: [][]stack.Service{{ {Service: "worker", State: "", Status: "Exit 0"}, }}, } withFakeRunner(t, fake) src := &source{} w := plugin.Workload{ ID: wid, Name: "fallback", SourceKind: "compose", SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}), } if err := src.Reconcile(context.Background(), deps, w); err != nil { t.Fatalf("Reconcile: %v", err) } row, err := deps.Store.GetContainerByID(wid + ":worker") if err != nil { t.Fatalf("get row: %v", err) } if row.State != "Exit 0" { t.Errorf("row.State = %q, want \"Exit 0\" (Status fallback)", row.State) } } // mustMarshalConfig is a small helper that converts a Config to the // raw-JSON shape SourceConfig expects. Tests use it instead of // hand-rolling the string so a Config field rename can't drift the test // fixture from the production decoder. func mustMarshalConfig(t *testing.T, cfg Config) json.RawMessage { t.Helper() b, err := json.Marshal(cfg) if err != nil { t.Fatalf("marshal config: %v", err) } return json.RawMessage(b) } // Compile-time guards: *stack.Compose must continue to satisfy // composeRunner so the production path keeps building, and the fake // must continue to satisfy it too so a drift in the interface shape // fails the build here rather than at runtime. var ( _ composeRunner = (*stack.Compose)(nil) _ composeRunner = (*fakeRunner)(nil) )