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:
2026-05-29 02:09:54 +03:00
parent 956943edbb
commit 410a131cec
112 changed files with 13285 additions and 2765 deletions
@@ -32,6 +32,23 @@ type Config struct {
type source struct{}
// composeRunner is the slice of stack.Compose this plugin actually
// drives. Defined locally per the "interfaces where they are used"
// idiom so the plugin can be unit-tested without a real docker compose
// binary. `*stack.Compose` satisfies it implicitly.
type composeRunner interface {
Up(ctx context.Context, projectName, yamlPath string) (string, error)
Down(ctx context.Context, projectName string, removeVolumes bool) (string, error)
Ps(ctx context.Context, projectName, yamlPath string) ([]stack.Service, error)
}
// newComposeRunner returns the runner the plugin should call. Tests
// swap this var with a fake; production code never touches it. The
// indirection costs one function-pointer dereference per Deploy /
// Teardown / Reconcile call — negligible against the docker compose
// exec it gates.
var newComposeRunner = func() composeRunner { return stack.NewCompose("") }
func init() { plugin.RegisterSource(&source{}) }
func (*source) Kind() string { return "compose" }
@@ -82,7 +99,7 @@ func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload,
return fmt.Errorf("compose source: write yaml: %w", err)
}
compose := stack.NewCompose("")
compose := newComposeRunner()
out, err := compose.Up(ctx, projectName, yamlPath)
if err != nil {
return fmt.Errorf("compose source: docker compose up: %w (output: %s)", err, truncate(out, 1024))
@@ -105,7 +122,7 @@ func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload
cfg, _ := plugin.SourceConfigOf[Config](w)
projectName := composeProjectName(cfg.ComposeProjectName, w)
compose := stack.NewCompose("")
compose := newComposeRunner()
if _, err := compose.Down(ctx, projectName, true); err != nil {
// Log but proceed — the DB rows must not be orphaned.
slog.Warn("compose source: docker compose down", "workload", w.ID, "error", err)
@@ -139,7 +156,7 @@ func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workloa
projectName := composeProjectName(cfg.ComposeProjectName, w)
yamlPath, _ := writeYAMLIfChanged(w.ID, cfg.ComposeYAML)
compose := stack.NewCompose("")
compose := newComposeRunner()
services, err := compose.Ps(ctx, projectName, yamlPath)
if err != nil {
// Likely no compose project running for this workload. Mark
@@ -162,7 +179,7 @@ func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workloa
// syncContainers shares its body with Reconcile minus the missing-row
// fallback — Deploy expects compose ps to succeed since `up` just ran.
func syncContainers(ctx context.Context, deps plugin.Deps, compose *stack.Compose, w plugin.Workload, projectName, yamlPath string) error {
func syncContainers(ctx context.Context, deps plugin.Deps, compose composeRunner, w plugin.Workload, projectName, yamlPath string) error {
services, err := compose.Ps(ctx, projectName, yamlPath)
if err != nil {
return fmt.Errorf("compose ps: %w", err)
@@ -204,7 +221,17 @@ var projectNameSanitizer = regexp.MustCompile(`[^a-z0-9_-]`)
func composeProjectName(explicit string, w plugin.Workload) string {
if explicit != "" {
return explicit
// Apply the same sanitizer to operator-supplied names so a value
// like "--foo" cannot reach the docker CLI and be re-parsed as a
// flag. Reuses the canonical lower+[^a-z0-9_-]→"-" + trim path.
san := strings.ToLower(explicit)
san = projectNameSanitizer.ReplaceAllString(san, "-")
san = strings.Trim(san, "-")
if san != "" {
return san
}
// Fall through to the derived name if sanitization stripped
// everything (operator passed e.g. "---" — degenerate input).
}
name := strings.ToLower(w.Name)
name = projectNameSanitizer.ReplaceAllString(name, "-")
@@ -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)
)