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
7 changes: 4 additions & 3 deletions internal/cli/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...]",
Expand All @@ -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`.
Expand Down Expand 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
}

Expand Down
65 changes: 31 additions & 34 deletions internal/orchestrate/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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))
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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{
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
136 changes: 136 additions & 0 deletions internal/orchestrate/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -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, " ")
Expand Down Expand Up @@ -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
}
Loading