feat(apps): stepped creation wizard, branch previews, and app-creation fixes
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.
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
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)
|
||||
)
|
||||
Reference in New Issue
Block a user