Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 36 additions & 18 deletions internal/orchestrate/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
53 changes: 51 additions & 2 deletions internal/orchestrate/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"])
}
}
Loading