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
42 changes: 42 additions & 0 deletions internal/orchestrate/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down Expand Up @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions internal/orchestrate/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading