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
21 changes: 21 additions & 0 deletions internal/cli/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"fmt"
"io"
"os"
"path/filepath"

Expand All @@ -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"
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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).
Expand Down
37 changes: 37 additions & 0 deletions internal/profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions internal/profile/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Loading