diff --git a/internal/cli/up.go b/internal/cli/up.go index ade660c..5cf8cae 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "io" "os" "path/filepath" @@ -14,6 +15,7 @@ import ( "github.com/open-source-cloud/devstack/internal/hooks" "github.com/open-source-cloud/devstack/internal/lock" "github.com/open-source-cloud/devstack/internal/orchestrate" + "github.com/open-source-cloud/devstack/internal/profile" "github.com/open-source-cloud/devstack/internal/state" "github.com/open-source-cloud/devstack/internal/workspace" "github.com/open-source-cloud/devstack/internal/xdg" @@ -48,6 +50,15 @@ func newUpCmd(g *GlobalOpts) *cobra.Command { d.NoPreflight = noPreflight d.Profiles = profiles + // Memory-budget warning (spec 12 §budget): opt-in — only when the + // workspace declares memoryBudgetMB and the active slice exceeds it. Never + // fatal; suppressed under --quiet/--json. + if !g.Quiet && !g.JSON { + if b := profile.CheckBudget(d.Model, profile.Resolve(d.Model, profiles)); b.Over { + warnMemoryBudget(cmd.ErrOrStderr(), b) + } + } + // Self-healing reconcile before the saga (spec 09): prune ref rows for // projects no longer live. Best-effort — never blocks `up`. _, _ = d.Manager.Reconcile(cmd.Context()) @@ -84,6 +95,16 @@ func newUpCmd(g *GlobalOpts) *cobra.Command { return cmd } +// warnMemoryBudget prints the spec-12 over-budget warning naming the offending +// active services and pointing at a lighter slice. +func warnMemoryBudget(w io.Writer, b profile.Budget) { + fmt.Fprintf(w, "[warn] active profile needs ~%d MB but memoryBudgetMB is %d MB:\n", b.TotalMB, b.BudgetMB) + for _, s := range b.Services { + fmt.Fprintf(w, " %s/%s: %d MB\n", s.Project, s.Service, s.MemoryMB) + } + fmt.Fprintln(w, " consider a smaller slice, e.g. `--profile minimal`") +} + // newDownCmd wires `devstack down [project...]` — stop project stacks, run // preDown hooks first, drop their ref rows. The external network and volumes are // never touched; shared services are left running (autostop is X1 config + spec 03). diff --git a/internal/profile/profile.go b/internal/profile/profile.go index be22480..bfd8c5a 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -65,6 +65,43 @@ func Resolve(m *config.Model, requested []string) Active { return out } +// ServiceMem is one active service's declared memory hint (spec 12/18). +type ServiceMem struct { + Project string `json:"project"` + Service string `json:"service"` + MemoryMB int `json:"memoryMB"` +} + +// Budget is the result of checking the active set against the workspace memory +// budget (spec 12 §budget). Over is false whenever no budget is configured. +type Budget struct { + BudgetMB int `json:"budgetMB"` // workspace.memoryBudgetMB (0 → no check) + TotalMB int `json:"totalMB"` // sum of active services' memoryMB + Over bool `json:"over"` // TotalMB > BudgetMB (only when BudgetMB > 0) + Services []ServiceMem `json:"services"` // active services that declared a memoryMB, sorted +} + +// CheckBudget sums the active services' declared memoryMB and compares it to the +// workspace memoryBudgetMB. With no budget configured (0), it never reports Over — +// the check is opt-in (spec 12 acceptance). Services with no memoryMB contribute +// nothing and are omitted from the breakdown. +func CheckBudget(m *config.Model, a Active) Budget { + b := Budget{BudgetMB: m.Workspace.MemoryBudgetMB} + for _, project := range sortedKeys(a.Services) { + p := m.Projects[project] + for _, sname := range a.Services[project] { + mb := p.Services[sname].MemoryMB + if mb <= 0 { + continue + } + b.TotalMB += mb + b.Services = append(b.Services, ServiceMem{Project: project, Service: sname, MemoryMB: mb}) + } + } + b.Over = b.BudgetMB > 0 && b.TotalMB > b.BudgetMB + return b +} + // 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 { diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 138bed9..b43566f 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -84,6 +84,39 @@ func TestResolveNoConfigDefaultIsAll(t *testing.T) { } } +func TestCheckBudgetOptInAndOver(t *testing.T) { + m := sliceModel() + // No budget configured → never Over, regardless of usage. + if CheckBudget(m, Resolve(m, []string{"all"})).Over { + t.Error("no memoryBudgetMB → must never report Over") + } + + // Give the frontend services memory hints and a tight budget. + app := m.Projects["app"] + web := app.Services["web"] + web.MemoryMB = 800 + app.Services["web"] = web + worker := app.Services["worker"] + worker.MemoryMB = 400 + app.Services["worker"] = worker + m.Projects["app"] = app + m.Workspace.MemoryBudgetMB = 1000 + + // frontend slice = web(800)+worker(400)=1200 > 1000 → Over, both named. + b := CheckBudget(m, Resolve(m, []string{"frontend"})) + if !b.Over || b.TotalMB != 1200 { + t.Fatalf("frontend budget = %+v, want Over with total 1200", b) + } + if len(b.Services) != 2 { + t.Errorf("offenders = %v, want web+worker", b.Services) + } + + // core slice = api(no memoryMB)=0 ≤ 1000 → not Over. + if CheckBudget(m, Resolve(m, []string{"core"})).Over { + t.Error("core slice (0 MB) must be under budget") + } +} + func TestResolveHasAndShared(t *testing.T) { a := Resolve(sliceModel(), []string{"core"}) if !a.Has("app", "api") || a.Has("app", "web") {