From 1df393350c3312b76cddc3d1c3f93ba192e370df Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 14:36:18 -0300 Subject: [PATCH] =?UTF-8?q?feat(profile):=20X4=20=E2=80=94=20selective-up?= =?UTF-8?q?=20profile=20resolver=20(spec=2012,=20Q-PROFILE=20RESOLVED)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New internal/profile.Resolve(model, requested) → the active service set + the shared instances those services transitively use, per the resolved Q-PROFILE decision: both planes (per-service `profiles:` tags + workspace `groups:`), unioned by name; `defaultProfile` opt-in; the reserved `all` is the no-config default. An explicit `--profile X` activates exactly the X slice (group members + tagged services), not the whole workspace — selective-up. `--profile a,b` and `--profile a --profile b` union identically. Pure logic, no daemon. Unblocks X5 (saga slices compose-up to Active) and the profile-aware DAG pruning (X2 + spec 10). The saga `--profile` wiring is the next step. Tests: default-profile slice, explicit tag slice (+ its shared union), comma/repeat union, reserved `all`, no-config default = all, Has/shared. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/profile/profile.go | 125 +++++++++++++++++++++++++++++++ internal/profile/profile_test.go | 92 +++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 internal/profile/profile.go create mode 100644 internal/profile/profile_test.go diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 0000000..be22480 --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,125 @@ +// Package profile resolves which services `up --profile` activates (spec 12, +// Q-PROFILE RESOLVED): service slices are declared at BOTH planes — per-service +// Compose `profiles:` tags and workspace-level `groups:` — unioned by name. +// `defaultProfile` is opt-in; the no-config default is the reserved `all` (every +// service). An explicit `--profile X` activates exactly the X slice (the services +// in group X plus those tagged X), never the whole workspace — that's the point +// of selective-up. The active set also pulls in the shared services those active +// services transitively `uses`. +// +// Pure config logic (no daemon): the saga consumes Active to slice compose-up and +// the health DAG prunes to the active nodes. +package profile + +import ( + "slices" + "sort" + "strings" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// All is the reserved profile name that activates the whole workspace. +const All = "all" + +// Active is the resolved selection: active services per project plus the shared +// instances they transitively use. +type Active struct { + Services map[string][]string `json:"services"` // project -> sorted active service names + Shared []string `json:"shared"` // sorted shared service names used by active services +} + +// Has reports whether a project's service is active. +func (a Active) Has(project, service string) bool { + return slices.Contains(a.Services[project], service) +} + +// Resolve computes the active set for the requested profiles (a repeatable, +// comma-separated union from --profile). Empty requested → defaultProfile, or the +// reserved `all` when none is configured. +func Resolve(m *config.Model, requested []string) Active { + profiles := normalize(requested) + if len(profiles) == 0 { + if dp := m.Workspace.DefaultProfile; dp != "" { + profiles[dp] = true + } else { + profiles[All] = true + } + } + all := profiles[All] + + out := Active{Services: map[string][]string{}} + for _, project := range sortedKeys(m.Projects) { + p := m.Projects[project] + var active []string + for _, sname := range sortedKeys(p.Services) { + if all || serviceActive(m, profiles, p.Services[sname], sname) { + active = append(active, sname) + } + } + if len(active) > 0 { + out.Services[project] = active + } + } + out.Shared = sharedUsedBy(m, out.Services) + return out +} + +// serviceActive reports whether a service is in any active group or carries an +// active profile tag. +func serviceActive(m *config.Model, profiles map[string]bool, svc config.Service, sname string) bool { + for name := range profiles { + if g, ok := m.Workspace.Groups[name]; ok && slices.Contains(g.Services, sname) { + return true + } + } + for _, tag := range svc.Profiles { + if profiles[tag] { + return true + } + } + return false +} + +// sharedUsedBy returns the sorted shared service names the active services use. +func sharedUsedBy(m *config.Model, active map[string][]string) []string { + seen := map[string]bool{} + for project, services := range active { + p := m.Projects[project] + for _, sname := range services { + for _, u := range p.Services[sname].Uses { + if ref, ok := config.ParseRef(u); ok && ref.Kind == config.RefShared { + seen[ref.Name] = true + } + } + } + } + out := make([]string, 0, len(seen)) + for name := range seen { + out = append(out, name) + } + sort.Strings(out) + return out +} + +// normalize splits comma-separated + repeated profile flags into a set. +func normalize(requested []string) map[string]bool { + out := map[string]bool{} + for _, r := range requested { + for part := range strings.SplitSeq(r, ",") { + if p := strings.TrimSpace(part); p != "" { + out[p] = true + } + } + } + return out +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go new file mode 100644 index 0000000..138bed9 --- /dev/null +++ b/internal/profile/profile_test.go @@ -0,0 +1,92 @@ +package profile + +import ( + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// model: shared pg/redis; project app with services api (group core, uses pg), +// web (tag frontend, uses pg+redis), worker (tag frontend), cron (no profile). +func sliceModel() *config.Model { + return &config.Model{ + Workspace: config.Workspace{ + DefaultProfile: "core", + Groups: map[string]config.Group{"core": {Services: []string{"api"}}}, + Shared: map[string]config.SharedSvc{"postgres": {}, "redis": {}}, + }, + Projects: map[string]config.Project{"app": {Services: map[string]config.Service{ + "api": {Uses: []string{"workspace.shared.postgres"}}, + "web": {Profiles: []string{"frontend"}, Uses: []string{"workspace.shared.postgres", "workspace.shared.redis"}}, + "worker": {Profiles: []string{"frontend"}}, + "cron": {}, + }}}, + } +} + +func active(t *testing.T, a Active) string { + t.Helper() + var parts []string + for _, s := range a.Services["app"] { + parts = append(parts, s) + } + return strings.Join(parts, ",") +} + +func TestResolveDefaultProfile(t *testing.T) { + // No --profile → defaultProfile "core" → only the core group (api). + a := Resolve(sliceModel(), nil) + if got := active(t, a); got != "api" { + t.Errorf("default(core) active = %q, want api", got) + } + if strings.Join(a.Shared, ",") != "postgres" { + t.Errorf("shared = %v, want [postgres] (api uses pg)", a.Shared) + } +} + +func TestResolveExplicitProfileTag(t *testing.T) { + // --profile frontend → services tagged frontend (web, worker), not api/cron. + a := Resolve(sliceModel(), []string{"frontend"}) + if got := active(t, a); got != "web,worker" { + t.Errorf("frontend active = %q, want web,worker", got) + } + // web uses pg+redis; worker uses none → shared = postgres,redis. + if strings.Join(a.Shared, ",") != "postgres,redis" { + t.Errorf("shared = %v, want [postgres redis]", a.Shared) + } +} + +func TestResolveUnionCommaAndRepeat(t *testing.T) { + // core ∪ frontend via comma and via repeat → identical (api,web,worker). + want := "api,web,worker" + if got := active(t, Resolve(sliceModel(), []string{"core,frontend"})); got != want { + t.Errorf("comma union = %q, want %q", got, want) + } + if got := active(t, Resolve(sliceModel(), []string{"core", "frontend"})); got != want { + t.Errorf("repeat union = %q, want %q", got, want) + } +} + +func TestResolveAllReserved(t *testing.T) { + // --profile all → every service regardless of defaultProfile. + if got := active(t, Resolve(sliceModel(), []string{"all"})); got != "api,cron,web,worker" { + t.Errorf("all = %q, want every service", got) + } +} + +func TestResolveNoConfigDefaultIsAll(t *testing.T) { + // No defaultProfile + no --profile → reserved `all` → every service. + m := sliceModel() + m.Workspace.DefaultProfile = "" + if got := active(t, Resolve(m, nil)); got != "api,cron,web,worker" { + t.Errorf("no-config default = %q, want every service (all)", got) + } +} + +func TestResolveHasAndShared(t *testing.T) { + a := Resolve(sliceModel(), []string{"core"}) + if !a.Has("app", "api") || a.Has("app", "web") { + t.Error("Has wrong for the core slice") + } +}