diff --git a/internal/orchestrate/up.go b/internal/orchestrate/up.go index 766930b..d324eab 100644 --- a/internal/orchestrate/up.go +++ b/internal/orchestrate/up.go @@ -155,6 +155,7 @@ func BuildUp(d UpDeps) ([]Phase, error) { } phases = append(phases, composeUpPhase(d, p, secretEnv, active.Services[p])) if !d.NoHooks { + phases = append(phases, firstRunPhase(d, p, d.Model.Projects[p].Hooks.FirstRun)) phases = append(phases, hookPhase(d, p, "postUp", d.Model.Projects[p].Hooks.PostUp, hooks.OnAbort)) } } @@ -516,6 +517,47 @@ func hookPhase(d UpDeps, scope, phaseName string, hookList []config.Hook, onFail } } +// firstRunPhase runs a project's `firstRun` hooks exactly once (spec 11): the +// first time the project comes up (e.g. seed/migrate against the freshly +// provisioned database). Idempotency is ledger-backed and keyed per hook NAME, so +// each firstRun hook runs once and is recorded under the flock; a re-`up` skips +// them. `workspace destroy`/`uninstall` clear the hook_run rows, so a fresh +// provision re-runs firstRun. It runs AFTER compose-up (the container — and its +// provisioned DB — exists) and before postUp. Default onFailure is abort: a failed +// first-time migration should stop the up rather than leave a half-seeded stack. +func firstRunPhase(d UpDeps, project string, hookList []config.Hook) Phase { + return Phase{ + Name: "firstRun", + Scope: project, + Run: func(ctx context.Context) (any, error) { + if len(hookList) == 0 { + return map[string]any{"ran": 0}, nil + } + outDir := filepath.Join(d.Model.ProjectDir(project), generate.GenDir) + runner := &hooks.Runner{ + Execer: hooks.OSExecer{ + BaseDir: d.Model.ProjectDir(project), + Project: "devstack-" + project, + File: filepath.Join(outDir, generate.ComposeFile), + }, + Ledger: d.DB, + Lock: func(ctx context.Context, fn func() error) error { return lock.WithLock(ctx, d.LockPath, fn) }, + } + results, err := runner.RunPhase(ctx, hookList, hooks.PhaseOpts{ + Project: project, + Phase: "firstRun", + Idempotent: true, + ScopeKey: func(h config.Hook) string { return h.Name }, + DefaultOnFailure: hooks.OnAbort, + }) + if err != nil { + return map[string]any{"results": results}, err + } + return map[string]any{"ran": len(results)}, nil + }, + } +} + // --- helpers --------------------------------------------------------------- func firstRunningID(cs []docker.Container) string { diff --git a/internal/orchestrate/up_test.go b/internal/orchestrate/up_test.go index 7134dac..751b510 100644 --- a/internal/orchestrate/up_test.go +++ b/internal/orchestrate/up_test.go @@ -661,3 +661,76 @@ func TestBuildUpNoProvisionSkips(t *testing.T) { } } } + +func TestFirstRunOncePerProject(t *testing.T) { + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("workspace.yaml", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n") + // firstRun appends a line to a counter file in the repo dir each time it runs. + write("app/devstack.yaml", `apiVersion: devstack/v1 +kind: Project +name: app +services: + web: { template: node.vite } +hooks: + firstRun: + - { name: seed, run: host, command: ["sh", "-c", "echo x >> firstrun.count"] } +`) + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load: %v", err) + } + db, err := state.Open(context.Background(), filepath.Join(root, "state"), "ctx") + if err != nil { + t.Fatalf("state: %v", err) + } + t.Cleanup(func() { db.Close() }) + src := template.NewFSSource(templates.FS) + lockPath := filepath.Join(root, "lock") + mc := &docker.MockClient{} + mgr := &workspace.Manager{Model: m, DB: db, Docker: mc, Source: src, LockPath: lockPath} + d := UpDeps{ + Model: m, DB: db, Docker: mc, Manager: mgr, Source: src, + LockPath: lockPath, Runner: &fakeRunner{}, Env: map[string]string{}, + NoPreflight: true, PgConnect: okPgConnect, + } + + saga := &Saga{Workspace: m.Workspace.Name, DB: db, LockPath: lockPath} + phases := mustPhases(t, d) + + // First up → firstRun runs. + if recs, err := saga.Run(context.Background(), phases); err != nil || AnyFailed(recs) { + t.Fatalf("up #1: %v\n%+v", err, recs) + } + countFile := filepath.Join(root, "app", "firstrun.count") + if b, _ := os.ReadFile(countFile); strings.Count(string(b), "x") != 1 { + t.Fatalf("after up #1 firstRun ran %d times, want 1", strings.Count(string(b), "x")) + } + + // Second up → firstRun is satisfied (ledger) and skips. + recs2, err := saga.Run(context.Background(), phases) + if err != nil || AnyFailed(recs2) { + t.Fatalf("up #2: %v\n%+v", err, recs2) + } + if b, _ := os.ReadFile(countFile); strings.Count(string(b), "x") != 1 { + t.Errorf("after up #2 firstRun ran again (count=%d), want still 1 (idempotent)", strings.Count(string(b), "x")) + } + // The firstRun phase reports skipped on the re-run. + var status string + for _, r := range recs2 { + if r.Phase == "firstRun" { + status = r.Status + } + } + if status != StatusOK { // AlwaysRun=false + satisfied → the phase still completes OK with all hooks skipped + t.Logf("firstRun re-run status = %q", status) + } +}