From 76d56896b42cd339e6426ad799b616f43b1b97e4 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 14:52:54 -0300 Subject: [PATCH] =?UTF-8?q?feat(orchestrate):=20X5=20=E2=80=94=20wire=20pr?= =?UTF-8?q?ofile=20slicing=20+=20shared=20pruning=20into=20the=20up=20saga?= =?UTF-8?q?=20(spec=2012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `up --profile` is now the spec-12 SERVICE-SLICE selector (repeatable & comma-separated, `-p` shorthand), distinct from the config-driven env-overlay (`profiles.default`, which still drives `${profile}` in generate). BuildUp resolves the active set via `profile.Resolve`: - projects with zero active services drop out of the up entirely; - each project's compose-up is restricted to its active service names (`compose up -d `), keyed into the phase fingerprint so widening or narrowing the slice re-runs rather than skips on a stale fingerprint; - the shared phase + health gate see only `active.Shared` (the shared instances the active services transitively `uses`), so `up --profile minimal` never blocks on an unstarted service. `down` stays whole-project (label-driven) per spec 12 §down. Removed the now-dead `sharedNamesUsedBy`. Unit-tested with the mock runner: frontend slice ups only `web` + gates postgres; no-config default ups everything (`all`); a no-match slice drops the project and its shared. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/up.go | 7 +- internal/orchestrate/up.go | 65 ++++++++------- internal/orchestrate/up_test.go | 136 ++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 37 deletions(-) diff --git a/internal/cli/up.go b/internal/cli/up.go index f59cc38..ade660c 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -27,7 +27,7 @@ func newUpCmd(g *GlobalOpts) *cobra.Command { build bool noHooks bool noPreflight bool - profile string + profiles []string ) cmd := &cobra.Command{ Use: "up [project...]", @@ -46,7 +46,7 @@ func newUpCmd(g *GlobalOpts) *cobra.Command { d.Build = build d.NoHooks = noHooks d.NoPreflight = noPreflight - d.Profile = profile + d.Profiles = profiles // Self-healing reconcile before the saga (spec 09): prune ref rows for // projects no longer live. Best-effort — never blocks `up`. @@ -79,7 +79,8 @@ func newUpCmd(g *GlobalOpts) *cobra.Command { cmd.Flags().BoolVar(&build, "build", false, "build images before starting (compose build)") cmd.Flags().BoolVar(&noHooks, "no-hooks", false, "skip lifecycle hooks") cmd.Flags().BoolVar(&noPreflight, "no-preflight", false, "skip the preflight checks") - cmd.Flags().StringVar(&profile, "profile", "", "env-overlay profile for ${profile}") + cmd.Flags().StringArrayVarP(&profiles, "profile", "p", nil, + "service slice(s) to start — repeatable & comma-separated (spec 12); empty → defaultProfile or all") return cmd } diff --git a/internal/orchestrate/up.go b/internal/orchestrate/up.go index 3d9dbc4..5532e8e 100644 --- a/internal/orchestrate/up.go +++ b/internal/orchestrate/up.go @@ -14,6 +14,7 @@ import ( "github.com/open-source-cloud/devstack/internal/health" "github.com/open-source-cloud/devstack/internal/hooks" "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/profile" "github.com/open-source-cloud/devstack/internal/secrets" "github.com/open-source-cloud/devstack/internal/state" "github.com/open-source-cloud/devstack/internal/template" @@ -46,8 +47,9 @@ type UpDeps struct { Runner docker.Runner // compose CLI runner (nil → docker.ExecRunner) Env map[string]string // generate env (nil → process env, via generate default) - Profile string - Projects []string // explicit subset; empty → every project in the workspace + Profile string // env-overlay profile for ${profile} (generate); "" → workspace default + Profiles []string // spec-12 SERVICE SLICES (--profile, repeatable); empty → defaultProfile/all + Projects []string // explicit subset; empty → every project in the workspace // Secrets resolves secret:// refs; nil → built from workspace.secrets.providers // with the built-in factories (SOPS+age). Injected for tests. Secrets *secrets.Registry @@ -76,6 +78,19 @@ func BuildUp(d UpDeps) ([]Phase, error) { } } + // Selective-up (spec 12): --profile slices the workspace to a set of active + // services + the shared instances they transitively use. Empty d.Profiles → + // defaultProfile, or the reserved `all` (every service). A project with no + // active services drops out of the up entirely. + active := profile.Resolve(d.Model, d.Profiles) + activeProjects := projects[:0:0] + for _, p := range projects { + if len(active.Services[p]) > 0 { + activeProjects = append(activeProjects, p) + } + } + projects = activeProjects + gen, err := generate.New(d.Model, d.Source, generate.WithEnv(d.Env), generate.WithProfile(d.Profile)) if err != nil { return nil, err @@ -95,7 +110,7 @@ func BuildUp(d UpDeps) ([]Phase, error) { generatePhase(d, gen), secretsPhase(d, projects, secretEnv), trustPhase(d), - sharedPhase(d, projects), + sharedPhase(d, projects, active.Shared), ) // Hook ordering (spec 11): workspace preUp → per-project (preUp → compose-up → // postUp) → workspace postUp. @@ -106,7 +121,7 @@ func BuildUp(d UpDeps) ([]Phase, error) { 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)) + phases = append(phases, composeUpPhase(d, p, secretEnv, active.Services[p])) if !d.NoHooks { phases = append(phases, hookPhase(d, p, "postUp", d.Model.Projects[p].Hooks.PostUp, hooks.OnAbort)) } @@ -278,8 +293,7 @@ func generatePhase(d UpDeps, gen *generate.Generator) Phase { // shared — register ref rows, bring up only the shared services the requested // projects use, then health-gate them. Compensation drops the ref rows. -func sharedPhase(d UpDeps, projects []string) Phase { - names := sharedNamesUsedBy(d.Model, projects) +func sharedPhase(d UpDeps, projects, names []string) Phase { return Phase{ Name: "shared", Mutating: true, @@ -361,7 +375,7 @@ func gateShared(ctx context.Context, d UpDeps, names []string) ([]map[string]any // composeUpPhase brings one project stack up. Compensation tears it back down // (idempotent) — refs are owned by the shared phase, not unwound here. -func composeUpPhase(d UpDeps, project string, secretEnv map[string][]string) Phase { +func composeUpPhase(d UpDeps, project string, secretEnv map[string][]string, services []string) Phase { outDir := filepath.Join(d.Model.ProjectDir(project), generate.GenDir) cp := func() docker.Compose { return docker.Compose{ @@ -379,19 +393,25 @@ func composeUpPhase(d UpDeps, project string, secretEnv map[string][]string) Pha Scope: project, Mutating: true, Fingerprint: func(context.Context) (string, error) { - return projectFingerprint(d.Model, project) + fp, err := projectFingerprint(d.Model, project) + if err != nil { + return "", err + } + // Key on the active slice too: narrowing/widening --profile must + // re-run compose-up rather than skip on a stale fingerprint. + return Fingerprint(append([]string{fp}, services...)...), nil }, Run: func(ctx context.Context) (any, error) { c := cp() if d.Build { - if err := c.Build(ctx, false); err != nil { + if err := c.Build(ctx, false, services...); err != nil { return nil, err } } - if err := c.Up(ctx); err != nil { + if err := c.Up(ctx, services...); err != nil { return nil, fmt.Errorf("compose up %s: %w", project, err) } - return map[string]any{"project": project}, nil + return map[string]any{"project": project, "services": services}, nil }, Compensate: func(ctx context.Context) error { c := cp() @@ -456,29 +476,6 @@ func firstRunningID(cs []docker.Container) string { return "" } -// sharedNamesUsedBy returns the sorted, de-duplicated shared service names the -// given projects consume via `uses`. -func sharedNamesUsedBy(m *config.Model, projects []string) []string { - seen := map[string]bool{} - var out []string - for _, project := range projects { - p, ok := m.Projects[project] - if !ok { - continue - } - for _, s := range p.Services { - for _, u := range s.Uses { - if ref, ok := config.ParseRef(u); ok && ref.Kind == config.RefShared && !seen[ref.Name] { - seen[ref.Name] = true - out = append(out, ref.Name) - } - } - } - } - sort.Strings(out) - return out -} - func sortedProjects(m *config.Model) []string { out := make([]string, 0, len(m.Projects)) for k := range m.Projects { diff --git a/internal/orchestrate/up_test.go b/internal/orchestrate/up_test.go index 4355c95..ce2d569 100644 --- a/internal/orchestrate/up_test.go +++ b/internal/orchestrate/up_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" "testing" @@ -61,6 +62,25 @@ func (f *fakeRunner) sawUp(project string) bool { } return false } + +// upServices returns the explicit service args passed to a project's compose up +// (everything after `up -d`), or nil if it was never up'd. +func (f *fakeRunner) upServices(project string) []string { + for _, c := range f.cmds { + joined := strings.Join(c, " ") + if !strings.Contains(joined, "-p "+project) || !strings.Contains(joined, " up ") { + continue + } + for i, tok := range c { + if tok == "-d" { + return append([]string(nil), c[i+1:]...) + } + } + return nil + } + return nil +} + func (f *fakeRunner) sawDown(project string) bool { for _, c := range f.cmds { joined := strings.Join(c, " ") @@ -402,3 +422,119 @@ func TestTrustPhaseFenced(t *testing.T) { t.Errorf("httpsLocal off should report skipped, got %v", detail) } } + +// sliceFixture builds a one-project workspace with two services: web (tagged +// `frontend`, uses postgres) and worker (untagged, uses nothing). It exercises +// selective-up (spec 12): --profile slices which services + shared come up. +func sliceFixture(t *testing.T) (UpDeps, *fakeRunner, *state.DB) { + t.Helper() + 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\nshared:\n postgres: { template: postgres, params: { version: \"16\" } }\nprojects:\n - { name: app, path: app }\n") + write("app/devstack.yaml", `apiVersion: devstack/v1 +kind: Project +name: app +services: + web: + template: node.vite + profiles: [frontend] + uses: [workspace.shared.postgres] + worker: + template: node.vite +`) + 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() }) + + mc := &docker.MockClient{ + Containers: []docker.Container{{ + ID: "pg1", Name: "devstack-shared-postgres-1", State: "running", + Labels: map[string]string{generate.LabelManaged: "true", generate.LabelShared: "postgres"}, + }}, + Details: map[string]docker.ContainerDetails{ + "pg1": {ID: "pg1", State: "running", Running: true, Health: docker.HealthHealthy}, + }, + } + src := template.NewFSSource(templates.FS) + lockPath := filepath.Join(root, "lock") + mgr := &workspace.Manager{Model: m, DB: db, Docker: mc, Source: src, LockPath: lockPath} + fr := &fakeRunner{} + d := UpDeps{ + Model: m, DB: db, Docker: mc, Manager: mgr, Source: src, + LockPath: lockPath, Runner: fr, Env: map[string]string{}, NoHooks: true, + } + return d, fr, db +} + +func TestBuildUpProfileSlicesServices(t *testing.T) { + // --profile frontend → only web is up'd (worker excluded); postgres is gated + // because web `uses` it. + d, fr, db := sliceFixture(t) + d.Profiles = []string{"frontend"} + recs, err := (&Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath}). + Run(context.Background(), mustPhases(t, d)) + if err != nil || AnyFailed(recs) { + t.Fatalf("saga: %v\n%+v", err, recs) + } + if got := fr.upServices("devstack-app"); len(got) != 1 || got[0] != "web" { + t.Errorf("frontend slice up'd %v, want [web] only", got) + } + if !fr.sawUp(generate.SharedStackName) { + t.Error("postgres should be up'd (web uses it)") + } +} + +func TestBuildUpDefaultProfileIsAll(t *testing.T) { + // No --profile + no defaultProfile → reserved `all` → both services up'd. + d, fr, db := sliceFixture(t) + recs, err := (&Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath}). + Run(context.Background(), mustPhases(t, d)) + if err != nil || AnyFailed(recs) { + t.Fatalf("saga: %v\n%+v", err, recs) + } + got := fr.upServices("devstack-app") + if len(got) != 2 || !slices.Contains(got, "web") || !slices.Contains(got, "worker") { + t.Errorf("default(all) up'd %v, want web+worker", got) + } +} + +func TestBuildUpProfileDropsInactiveProjectAndShared(t *testing.T) { + // A slice that matches no service → the project drops out entirely and no + // shared is gated (nothing uses it). + d, fr, db := sliceFixture(t) + d.Profiles = []string{"nonexistent"} + recs, err := (&Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath}). + Run(context.Background(), mustPhases(t, d)) + if err != nil || AnyFailed(recs) { + t.Fatalf("saga: %v\n%+v", err, recs) + } + if fr.sawUp("devstack-app") { + t.Error("no service active → project must not be up'd") + } + if fr.sawUp(generate.SharedStackName) { + t.Error("no active service uses shared → shared must not be up'd") + } +} + +func mustPhases(t *testing.T, d UpDeps) []Phase { + t.Helper() + phases, err := BuildUp(d) + if err != nil { + t.Fatalf("BuildUp: %v", err) + } + return phases +}