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
125 changes: 125 additions & 0 deletions internal/profile/profile.go
Original file line number Diff line number Diff line change
@@ -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
}
92 changes: 92 additions & 0 deletions internal/profile/profile_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading