Skip to content

Commit 3311345

Browse files
gustavobertoiclaude
andcommitted
feat(profile): spec-12 memoryBudgetMB warning on up (X5 follow-up)
Closes the last spec-12 acceptance item: with `memoryBudgetMB` set and the active profile's summed `memoryMB` exceeding it, `up` prints a non-fatal warning naming the offending services and suggesting a smaller slice (`--profile minimal`). With no budget configured, no check runs (opt-in). - `profile.CheckBudget(model, active)` → {BudgetMB, TotalMB, Over, Services}; pure, unit-tested (opt-in off; frontend slice over budget names both services; a zero-MB slice stays under). - Wired into the up CLI before the saga; suppressed under --quiet/--json. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8befea1 commit 3311345

3 files changed

Lines changed: 91 additions & 0 deletions

File tree

internal/cli/up.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"context"
55
"fmt"
6+
"io"
67
"os"
78
"path/filepath"
89

@@ -14,6 +15,7 @@ import (
1415
"github.com/open-source-cloud/devstack/internal/hooks"
1516
"github.com/open-source-cloud/devstack/internal/lock"
1617
"github.com/open-source-cloud/devstack/internal/orchestrate"
18+
"github.com/open-source-cloud/devstack/internal/profile"
1719
"github.com/open-source-cloud/devstack/internal/state"
1820
"github.com/open-source-cloud/devstack/internal/workspace"
1921
"github.com/open-source-cloud/devstack/internal/xdg"
@@ -48,6 +50,15 @@ func newUpCmd(g *GlobalOpts) *cobra.Command {
4850
d.NoPreflight = noPreflight
4951
d.Profiles = profiles
5052

53+
// Memory-budget warning (spec 12 §budget): opt-in — only when the
54+
// workspace declares memoryBudgetMB and the active slice exceeds it. Never
55+
// fatal; suppressed under --quiet/--json.
56+
if !g.Quiet && !g.JSON {
57+
if b := profile.CheckBudget(d.Model, profile.Resolve(d.Model, profiles)); b.Over {
58+
warnMemoryBudget(cmd.ErrOrStderr(), b)
59+
}
60+
}
61+
5162
// Self-healing reconcile before the saga (spec 09): prune ref rows for
5263
// projects no longer live. Best-effort — never blocks `up`.
5364
_, _ = d.Manager.Reconcile(cmd.Context())
@@ -84,6 +95,16 @@ func newUpCmd(g *GlobalOpts) *cobra.Command {
8495
return cmd
8596
}
8697

98+
// warnMemoryBudget prints the spec-12 over-budget warning naming the offending
99+
// active services and pointing at a lighter slice.
100+
func warnMemoryBudget(w io.Writer, b profile.Budget) {
101+
fmt.Fprintf(w, "[warn] active profile needs ~%d MB but memoryBudgetMB is %d MB:\n", b.TotalMB, b.BudgetMB)
102+
for _, s := range b.Services {
103+
fmt.Fprintf(w, " %s/%s: %d MB\n", s.Project, s.Service, s.MemoryMB)
104+
}
105+
fmt.Fprintln(w, " consider a smaller slice, e.g. `--profile minimal`")
106+
}
107+
87108
// newDownCmd wires `devstack down [project...]` — stop project stacks, run
88109
// preDown hooks first, drop their ref rows. The external network and volumes are
89110
// never touched; shared services are left running (autostop is X1 config + spec 03).

internal/profile/profile.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,43 @@ func Resolve(m *config.Model, requested []string) Active {
6565
return out
6666
}
6767

68+
// ServiceMem is one active service's declared memory hint (spec 12/18).
69+
type ServiceMem struct {
70+
Project string `json:"project"`
71+
Service string `json:"service"`
72+
MemoryMB int `json:"memoryMB"`
73+
}
74+
75+
// Budget is the result of checking the active set against the workspace memory
76+
// budget (spec 12 §budget). Over is false whenever no budget is configured.
77+
type Budget struct {
78+
BudgetMB int `json:"budgetMB"` // workspace.memoryBudgetMB (0 → no check)
79+
TotalMB int `json:"totalMB"` // sum of active services' memoryMB
80+
Over bool `json:"over"` // TotalMB > BudgetMB (only when BudgetMB > 0)
81+
Services []ServiceMem `json:"services"` // active services that declared a memoryMB, sorted
82+
}
83+
84+
// CheckBudget sums the active services' declared memoryMB and compares it to the
85+
// workspace memoryBudgetMB. With no budget configured (0), it never reports Over —
86+
// the check is opt-in (spec 12 acceptance). Services with no memoryMB contribute
87+
// nothing and are omitted from the breakdown.
88+
func CheckBudget(m *config.Model, a Active) Budget {
89+
b := Budget{BudgetMB: m.Workspace.MemoryBudgetMB}
90+
for _, project := range sortedKeys(a.Services) {
91+
p := m.Projects[project]
92+
for _, sname := range a.Services[project] {
93+
mb := p.Services[sname].MemoryMB
94+
if mb <= 0 {
95+
continue
96+
}
97+
b.TotalMB += mb
98+
b.Services = append(b.Services, ServiceMem{Project: project, Service: sname, MemoryMB: mb})
99+
}
100+
}
101+
b.Over = b.BudgetMB > 0 && b.TotalMB > b.BudgetMB
102+
return b
103+
}
104+
68105
// serviceActive reports whether a service is in any active group or carries an
69106
// active profile tag.
70107
func serviceActive(m *config.Model, profiles map[string]bool, svc config.Service, sname string) bool {

internal/profile/profile_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,39 @@ func TestResolveNoConfigDefaultIsAll(t *testing.T) {
8484
}
8585
}
8686

87+
func TestCheckBudgetOptInAndOver(t *testing.T) {
88+
m := sliceModel()
89+
// No budget configured → never Over, regardless of usage.
90+
if CheckBudget(m, Resolve(m, []string{"all"})).Over {
91+
t.Error("no memoryBudgetMB → must never report Over")
92+
}
93+
94+
// Give the frontend services memory hints and a tight budget.
95+
app := m.Projects["app"]
96+
web := app.Services["web"]
97+
web.MemoryMB = 800
98+
app.Services["web"] = web
99+
worker := app.Services["worker"]
100+
worker.MemoryMB = 400
101+
app.Services["worker"] = worker
102+
m.Projects["app"] = app
103+
m.Workspace.MemoryBudgetMB = 1000
104+
105+
// frontend slice = web(800)+worker(400)=1200 > 1000 → Over, both named.
106+
b := CheckBudget(m, Resolve(m, []string{"frontend"}))
107+
if !b.Over || b.TotalMB != 1200 {
108+
t.Fatalf("frontend budget = %+v, want Over with total 1200", b)
109+
}
110+
if len(b.Services) != 2 {
111+
t.Errorf("offenders = %v, want web+worker", b.Services)
112+
}
113+
114+
// core slice = api(no memoryMB)=0 ≤ 1000 → not Over.
115+
if CheckBudget(m, Resolve(m, []string{"core"})).Over {
116+
t.Error("core slice (0 MB) must be under budget")
117+
}
118+
}
119+
87120
func TestResolveHasAndShared(t *testing.T) {
88121
a := Resolve(sliceModel(), []string{"core"})
89122
if !a.Has("app", "api") || a.Has("app", "web") {

0 commit comments

Comments
 (0)