410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
513 lines
15 KiB
Go
513 lines
15 KiB
Go
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)
|
|
)
|