diff --git a/internal/orchestrate/up.go b/internal/orchestrate/up.go index 1648a48..b48b4c4 100644 --- a/internal/orchestrate/up.go +++ b/internal/orchestrate/up.go @@ -92,13 +92,22 @@ func BuildUp(d UpDeps) ([]Phase, error) { secretsPhase(d, projects, secretEnv), sharedPhase(d, projects), ) + // Hook ordering (spec 11): workspace preUp → per-project (preUp → compose-up → + // postUp) → workspace postUp. + if !d.NoHooks { + phases = append(phases, hookPhase(d, "", "preUp", d.Model.Workspace.Hooks.PreUp, hooks.OnAbort)) + } for _, p := range projects { + if !d.NoHooks { + phases = append(phases, hookPhase(d, p, "preUp", d.Model.Projects[p].Hooks.PreUp, hooks.OnAbort)) + } phases = append(phases, composeUpPhase(d, p, secretEnv)) + if !d.NoHooks { + phases = append(phases, hookPhase(d, p, "postUp", d.Model.Projects[p].Hooks.PostUp, hooks.OnAbort)) + } } if !d.NoHooks { - for _, p := range projects { - phases = append(phases, hooksPhase(d, p)) - } + phases = append(phases, hookPhase(d, "", "postUp", d.Model.Workspace.Hooks.PostUp, hooks.OnAbort)) } return phases, nil } @@ -364,28 +373,37 @@ func composeUpPhase(d UpDeps, project string, secretEnv map[string][]string) Pha // hooksPhase runs a project's postUp hooks (unconditional). firstRun/postPull // (idempotent, ledger-keyed) arrive with provision/git wiring. -func hooksPhase(d UpDeps, project string) Phase { +// hookPhase runs one lifecycle-hook list at a saga phase boundary (spec 11). +// scope is a project name, or "" for workspace-scope hooks (run from the +// workspace root with no compose project). Empty lists are a no-op. +func hookPhase(d UpDeps, scope, phaseName string, hookList []config.Hook, onFail string) Phase { return Phase{ - Name: "hooks", - Scope: project, + Name: phaseName, // preUp | postUp + Scope: scope, AlwaysRun: true, Run: func(ctx context.Context) (any, error) { - p := d.Model.Projects[project] - if len(p.Hooks.PostUp) == 0 { + 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, + var ex hooks.Execer + ledgerProject := scope + if scope == "" { + ex = hooks.OSExecer{BaseDir: d.Model.Root} + ledgerProject = "workspace" + } else { + outDir := filepath.Join(d.Model.ProjectDir(scope), generate.GenDir) + ex = hooks.OSExecer{ + BaseDir: d.Model.ProjectDir(scope), + Project: "devstack-" + scope, 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) }, + } + } + runner := &hooks.Runner{ + Execer: ex, Ledger: d.DB, + Lock: func(ctx context.Context, fn func() error) error { return lock.WithLock(ctx, d.LockPath, fn) }, } - results, err := runner.RunPhase(ctx, p.Hooks.PostUp, hooks.PhaseOpts{ - Project: project, Phase: "postUp", DefaultOnFailure: hooks.OnAbort, + results, err := runner.RunPhase(ctx, hookList, hooks.PhaseOpts{ + Project: ledgerProject, Phase: phaseName, DefaultOnFailure: onFail, }) if err != nil { return map[string]any{"results": results}, err diff --git a/internal/orchestrate/up_test.go b/internal/orchestrate/up_test.go index 01701f5..8de78e9 100644 --- a/internal/orchestrate/up_test.go +++ b/internal/orchestrate/up_test.go @@ -145,7 +145,7 @@ func TestBuildUpHappyPath(t *testing.T) { for _, r := range recs { got[r.Phase+scopeSuffix(r.Scope)] = r.Status } - for _, want := range []string{"preflight", "network", "generate", "shared", "compose-up@app", "hooks@app"} { + for _, want := range []string{"preflight", "network", "generate", "shared", "compose-up@app", "postUp@app"} { if got[want] != StatusOK { t.Errorf("phase %q = %q, want ok (all: %+v)", want, got[want], got) } @@ -178,7 +178,7 @@ func TestBuildUpHappyPath(t *testing.T) { } for _, r := range recs2 { switch r.Phase { - case "preflight", "secrets", "hooks": // AlwaysRun phases + case "preflight", "secrets", "preUp", "postUp": // AlwaysRun phases if r.Status != StatusOK { t.Errorf("%s should re-run ok, got %q", r.Phase, r.Status) } @@ -314,3 +314,52 @@ func TestBuildUpInjectsSecretEnv(t *testing.T) { t.Errorf("compose-up env should carry DB_PASSWORD=resolved-pw, got %v", env) } } + +func TestBuildUpHookOrdering(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\nhooks:\n preUp:\n - { name: ws-banner, run: host, command: [\"true\"] }\nprojects:\n - { name: app, path: app }\n") + write("app/devstack.yaml", "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite }\nhooks:\n preUp:\n - { name: migrate, run: host, command: [\"true\"] }\n") + + m, err := config.LoadAt(root) + if err != nil { + t.Fatal(err) + } + db, _ := state.Open(context.Background(), filepath.Join(root, "state"), "ctx") + t.Cleanup(func() { db.Close() }) + mc := &docker.MockClient{} + src := template.NewFSSource(templates.FS) + lockPath := filepath.Join(root, "lock") + d := UpDeps{ + Model: m, DB: db, Docker: mc, + Manager: &workspace.Manager{Model: m, DB: db, Docker: mc, Source: src, LockPath: lockPath}, + Source: src, LockPath: lockPath, Runner: &fakeRunner{}, Env: map[string]string{}, + } + phases, err := BuildUp(d) + if err != nil { + t.Fatal(err) + } + saga := &Saga{Workspace: "demo", DB: db, LockPath: lockPath} + recs, err := saga.Run(context.Background(), phases) + if err != nil { + t.Fatalf("saga: %v", err) + } + // Record the order of the phases we care about. + idx := map[string]int{} + for i, r := range recs { + idx[r.Phase+scopeSuffix(r.Scope)] = i + } + // workspace preUp ("preUp", no scope) before project preUp before compose-up. + if !(idx["preUp"] < idx["preUp@app"] && idx["preUp@app"] < idx["compose-up@app"]) { + t.Errorf("hook ordering wrong: ws-preUp=%d app-preUp=%d compose-up=%d", + idx["preUp"], idx["preUp@app"], idx["compose-up@app"]) + } +}