From 50f0a8015a459314bb2e1ca8bfdcdb04c7da61b1 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 00:35:15 -0300 Subject: [PATCH] feat(generate): compose resource limits + multi-arch platform (spec 18) Lower a per-service resources block and a platform selector into the typed compose model (spec 18), for BOTH project and shared stacks. - config: add Service/SharedSvc.Resources{cpus,memoryMB,memoryReserveMB, pidsLimit} + Platform, with cpus/platform validators and Service/SharedSvc EffectiveMemoryMB(). memoryMB stays the budget hint and is shorthand for resources.memoryMB. - generate: applyResources dual-writes limits from one canonical byte value (deploy.resources.limits.* AND legacy top-level cpus/mem_limit/pids_limit) so compose-go/v2 cross-field consistency passes; emits platform; a service with no limits emits no deploy block (no spurious diff). Bytes rendered as a fixed mebibyte->bytes string (768M -> 805306368) for determinism. - profile: CheckBudget sums the effective memory limit. - goldens regenerated; new table-driven tests in config + generate. Tested: CGO_ENABLED=0 go build ./... + all four release cross-builds, CGO_ENABLED=1 go test ./internal/..., gofmt -l clean, go vet, make determinism. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/config/model.go | 51 ++++++- internal/config/resource_limits_test.go | 127 ++++++++++++++++ .../testdata/valid/services/api/devstack.yaml | 5 + internal/config/testdata/valid/workspace.yaml | 5 +- internal/config/validate.go | 21 +++ internal/generate/compose.go | 89 +++++++++++ internal/generate/resources_test.go | 138 ++++++++++++++++++ .../golden/devstack-api.docker-compose.yaml | 12 ++ .../devstack-shared.docker-compose.yaml | 9 ++ internal/profile/profile.go | 11 +- 10 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 internal/config/resource_limits_test.go create mode 100644 internal/generate/resources_test.go diff --git a/internal/config/model.go b/internal/config/model.go index 409f192..15787a5 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -84,9 +84,24 @@ type Tunnel struct { // SharedSvc is one shared infrastructure service (postgres/redis/minio/...), // rendered from a template (spec 03). Reached by alias DNS, ref-counted. +// +// Resources/Platform (spec 18) apply the same CPU/memory limits + arch selector +// to the shared stack that project services get; changing a shared limit is a +// stateful-service restart, gated behind the same explicit confirm as any shared +// recreate (spec 03) — never silent. type SharedSvc struct { - Template string `yaml:"template" validate:"required"` - Params map[string]any `yaml:"params"` + Template string `yaml:"template" validate:"required"` + Params map[string]any `yaml:"params"` + Resources *Resources `yaml:"resources"` // spec 18 — CPU/memory limits + Platform string `yaml:"platform" validate:"omitempty,platform"` // spec 18 — e.g. linux/amd64 +} + +// EffectiveMemoryMB is the shared service's hard memory limit in MB (0 = unset). +func (s SharedSvc) EffectiveMemoryMB() int { + if s.Resources != nil { + return s.Resources.MemoryMB + } + return 0 } // ProjectRef points workspace.yaml at a repo containing a devstack.yaml. @@ -130,10 +145,34 @@ type Service struct { Uses []string `yaml:"uses"` // consume SHARED services: workspace.shared. Env Env `yaml:"env"` Ports map[string]int `yaml:"ports"` - Profiles []string `yaml:"profiles"` // spec 12 — Compose profile membership tags - MemoryMB int `yaml:"memoryMB"` // spec 12/18 — per-service budget hint (reserved) - Healthcheck *Healthcheck `yaml:"healthcheck"` // spec 10 — readiness probe (nil = none) - DependsOn []DependsOn `yaml:"dependsOn" validate:"dive"` // spec 10 — ordering edges + Profiles []string `yaml:"profiles"` // spec 12 — Compose profile membership tags + MemoryMB int `yaml:"memoryMB"` // spec 12/18 — budget hint == shorthand for resources.memoryMB + Resources *Resources `yaml:"resources"` // spec 18 — CPU/memory/pids limits (nil = none) + Platform string `yaml:"platform" validate:"omitempty,platform"` // spec 18 — arch selector, e.g. linux/amd64 + Healthcheck *Healthcheck `yaml:"healthcheck"` // spec 10 — readiness probe (nil = none) + DependsOn []DependsOn `yaml:"dependsOn" validate:"dive"` // spec 10 — ordering edges +} + +// Resources is the spec-18 per-service resource-limit block. It lowers to a +// deterministic dual-write in the generated compose (deploy.resources.limits.* +// AND the legacy top-level cpus/mem_limit/pids_limit), so both the deploy-aware +// and non-deploy compose readers honor the same canonical values. All fields are +// optional; an omitted field emits nothing. See docs/specs/18. +type Resources struct { + CPUs string `yaml:"cpus" validate:"omitempty,cpus"` // fractional cores, e.g. "1.5" + MemoryMB int `yaml:"memoryMB" validate:"omitempty,gte=0"` // hard memory limit + MemoryReserveMB int `yaml:"memoryReserveMB" validate:"omitempty,gte=0"` // soft reservation (scheduling hint) + PidsLimit int `yaml:"pidsLimit" validate:"omitempty,gte=0"` // max PIDs +} + +// EffectiveMemoryMB is the service's hard memory limit in MB: resources.memoryMB +// when set, else the top-level memoryMB shorthand (spec 18). 0 means unset — used +// for both the emitted mem_limit and the budget summation so the two never drift. +func (s Service) EffectiveMemoryMB() int { + if s.Resources != nil && s.Resources.MemoryMB > 0 { + return s.Resources.MemoryMB + } + return s.MemoryMB } // Healthcheck declares a service's readiness probe (spec 10). It compiles to diff --git a/internal/config/resource_limits_test.go b/internal/config/resource_limits_test.go new file mode 100644 index 0000000..dbb194a --- /dev/null +++ b/internal/config/resource_limits_test.go @@ -0,0 +1,127 @@ +package config + +import ( + "strings" + "testing" +) + +// resLimitsProject wraps a service body into a minimal but valid two-file tree. +func resLimitsProject(t *testing.T, svcBody string) (*Model, error) { + t.Helper() + ws := `apiVersion: devstack/v1 +kind: Workspace +name: acme +shared: + postgres: { template: postgres } +projects: + - { name: api, path: api } +` + proj := "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api:\n template: t\n" + svcBody + root := writeTree(t, map[string]string{"workspace.yaml": ws, "api/devstack.yaml": proj}) + return LoadAt(root) +} + +// TestResourceLimitsParse — the spec-18 resources block + platform selector parse +// onto a project service, and EffectiveMemoryMB prefers resources.memoryMB. +func TestResourceLimitsParse(t *testing.T) { + m, err := resLimitsProject(t, ` platform: linux/amd64 + resources: + cpus: "1.5" + memoryMB: 512 + memoryReserveMB: 256 + pidsLimit: 128 +`) + if err != nil { + t.Fatalf("LoadAt: %v", err) + } + svc := m.Projects["api"].Services["api"] + if svc.Platform != "linux/amd64" { + t.Errorf("platform = %q, want linux/amd64", svc.Platform) + } + if svc.Resources == nil { + t.Fatal("resources block did not parse") + } + if svc.Resources.CPUs != "1.5" || svc.Resources.MemoryMB != 512 || + svc.Resources.MemoryReserveMB != 256 || svc.Resources.PidsLimit != 128 { + t.Errorf("resources = %+v", *svc.Resources) + } + if got := svc.EffectiveMemoryMB(); got != 512 { + t.Errorf("EffectiveMemoryMB = %d, want 512 (resources.memoryMB wins)", got) + } +} + +// TestEffectiveMemoryShorthand — with no resources.memoryMB, the top-level +// memoryMB shorthand is the effective limit; with neither, it is 0. +func TestEffectiveMemoryShorthand(t *testing.T) { + m, err := resLimitsProject(t, " memoryMB: 768\n") + if err != nil { + t.Fatalf("LoadAt: %v", err) + } + if got := m.Projects["api"].Services["api"].EffectiveMemoryMB(); got != 768 { + t.Errorf("EffectiveMemoryMB = %d, want 768 (shorthand)", got) + } + + m2, err := resLimitsProject(t, "") + if err != nil { + t.Fatalf("LoadAt: %v", err) + } + if got := m2.Projects["api"].Services["api"].EffectiveMemoryMB(); got != 0 { + t.Errorf("EffectiveMemoryMB = %d, want 0 (unset)", got) + } +} + +// TestPlatformValidation — a malformed platform selector is rejected with a +// file-scoped error; well-formed selectors pass. +func TestPlatformValidation(t *testing.T) { + if _, err := resLimitsProject(t, " platform: not-a-platform\n"); err == nil || + !strings.Contains(err.Error(), "platform") { + t.Fatalf("want a platform validation error, got %v", err) + } + for _, p := range []string{"linux/amd64", "linux/arm64/v8", "darwin/arm64"} { + if _, err := resLimitsProject(t, " platform: "+p+"\n"); err != nil { + t.Errorf("platform %q should be valid, got %v", p, err) + } + } +} + +// TestCPUsValidation — a non-numeric or non-positive cpus quantity is rejected. +func TestCPUsValidation(t *testing.T) { + for _, bad := range []string{"lots", "0", "-1"} { + if _, err := resLimitsProject(t, " resources: { cpus: \""+bad+"\" }\n"); err == nil || + !strings.Contains(err.Error(), "cpu") { + t.Errorf("cpus %q should be rejected, got %v", bad, err) + } + } + if _, err := resLimitsProject(t, " resources: { cpus: \"1.5\" }\n"); err != nil { + t.Errorf("cpus 1.5 should be valid, got %v", err) + } +} + +// TestSharedResourcesParse — shared services accept the same resources/platform +// knobs from workspace.yaml (spec 18, shared stack). +func TestSharedResourcesParse(t *testing.T) { + ws := `apiVersion: devstack/v1 +kind: Workspace +name: acme +shared: + postgres: + template: postgres + platform: linux/amd64 + resources: { cpus: "2", memoryMB: 1024 } +projects: + - { name: api, path: api } +` + proj := "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api: { template: t }\n" + root := writeTree(t, map[string]string{"workspace.yaml": ws, "api/devstack.yaml": proj}) + m, err := LoadAt(root) + if err != nil { + t.Fatalf("LoadAt: %v", err) + } + pg := m.Workspace.Shared["postgres"] + if pg.Platform != "linux/amd64" { + t.Errorf("shared platform = %q, want linux/amd64", pg.Platform) + } + if pg.EffectiveMemoryMB() != 1024 { + t.Errorf("shared EffectiveMemoryMB = %d, want 1024", pg.EffectiveMemoryMB()) + } +} diff --git a/internal/config/testdata/valid/services/api/devstack.yaml b/internal/config/testdata/valid/services/api/devstack.yaml index a4ba81b..edef2c2 100644 --- a/internal/config/testdata/valid/services/api/devstack.yaml +++ b/internal/config/testdata/valid/services/api/devstack.yaml @@ -6,6 +6,11 @@ services: template: php.laravel.nginx params: { phpVersion: "8.3" } memoryMB: 768 + platform: linux/amd64 + resources: + cpus: "1.5" + memoryReserveMB: 256 + pidsLimit: 512 uses: - workspace.shared.postgres - workspace.shared.redis diff --git a/internal/config/testdata/valid/workspace.yaml b/internal/config/testdata/valid/workspace.yaml index 987e8ad..5d3f9ac 100644 --- a/internal/config/testdata/valid/workspace.yaml +++ b/internal/config/testdata/valid/workspace.yaml @@ -12,7 +12,10 @@ hooks: preUp: - { name: banner, run: host, command: ["true"] } shared: - postgres: { template: postgres, params: { version: "16" } } + postgres: + template: postgres + params: { version: "16" } + resources: { cpus: "2", memoryMB: 1024, memoryReserveMB: 512 } redis: { template: redis, params: { version: "7" } } minio: { template: minio } projects: diff --git a/internal/config/validate.go b/internal/config/validate.go index 534cea7..e79850a 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "sort" + "strconv" "strings" "time" @@ -33,9 +34,23 @@ func newValidator() *validator.Validate { _, err := time.ParseDuration(fl.Field().String()) return err == nil }) + // cpus: a positive fractional-core string ("1.5", "2"). Emitted verbatim into + // the compose cpus/limits.cpus fields (spec 18); must parse as a float > 0. + _ = v.RegisterValidation("cpus", func(fl validator.FieldLevel) bool { + f, err := strconv.ParseFloat(fl.Field().String(), 64) + return err == nil && f > 0 + }) + // platform: an os/arch[/variant] selector ("linux/amd64", "linux/arm64/v8"). + _ = v.RegisterValidation("platform", func(fl validator.FieldLevel) bool { + return platformRE.MatchString(fl.Field().String()) + }) return v } +// platformRE matches a compose `platform:` selector: os/arch with an optional +// variant (e.g. linux/amd64, linux/arm64/v8). +var platformRE = regexp.MustCompile(`^[a-z0-9]+/[a-z0-9]+(/[a-z0-9]+)?$`) + // structValidate runs validator/v10, recovering from the panic it raises on a // malformed tag (DECISIONS D16) so a tag bug never crashes the CLI. func structValidate(v any) (err error) { @@ -193,6 +208,12 @@ func describeFieldError(fe validator.FieldError) string { return fmt.Sprintf("%s = %q must be one of: %s", field, fe.Value(), strings.ReplaceAll(fe.Param(), " ", ", ")) case "duration": return fmt.Sprintf("%s = %q is not a valid duration (e.g. \"5s\", \"1m30s\")", field, fe.Value()) + case "cpus": + return fmt.Sprintf("%s = %q is not a valid cpu quantity (a positive number of cores, e.g. \"1.5\")", field, fe.Value()) + case "platform": + return fmt.Sprintf("%s = %q is not a valid platform (expected os/arch, e.g. \"linux/amd64\")", field, fe.Value()) + case "gte": + return fmt.Sprintf("%s must be >= %s", field, fe.Param()) case "min": return fmt.Sprintf("%s must have at least %s element(s)", field, fe.Param()) default: diff --git a/internal/generate/compose.go b/internal/generate/compose.go index d823b7c..38177dc 100644 --- a/internal/generate/compose.go +++ b/internal/generate/compose.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "sort" + "strconv" "strings" "github.com/compose-spec/compose-go/v2/loader" @@ -106,6 +107,11 @@ func buildProjectService(res *graphResolver, m *config.Model, project, service s out["expose"] = exp } + // spec 18 — CPU/memory/pids limits + arch selector. memoryMB is the shorthand + // for resources.memoryMB; the effective value drives both the emitted limit and + // the up/doctor budget sum so the two never drift. + applyResources(out, svc.Resources, svc.MemoryMB, svc.Platform) + // spec 10 — a service-declared healthcheck overrides any template default and // is lowered to a Compose-native healthcheck: block. if svc.Healthcheck != nil { @@ -151,9 +157,92 @@ func buildSharedService(m *config.Model, name string, resolved *template.Resolve SharedNetwork: map[string]any{"aliases": []any{sharedAlias(name)}}, } out["labels"] = b.labels(map[string]string{LabelShared: name}) + + // spec 18 — the same CPU/memory limits + arch selector apply to the shared + // stack; declared in workspace.yaml shared..resources. + ss := m.Workspace.Shared[name] + applyResources(out, ss.Resources, 0, ss.Platform) return out, nil } +// applyResources lowers a spec-18 resources block + platform selector onto a +// compose service map. Limits are DUAL-WRITTEN from one canonical byte value: +// deploy.resources.limits.* (the spec-blessed path compose v2 honors on plain +// containers) AND the legacy top-level cpus/mem_limit/pids_limit, so both the +// deploy-aware and non-deploy readers see identical values and compose-go/v2's +// cross-field consistency check passes. A service that declares no limits emits +// no deploy block at all (no spurious diff). memoryMB is the shorthand fed as the +// baseline memory limit; an explicit resources.memoryMB overrides it. +func applyResources(out map[string]any, res *config.Resources, memoryMB int, platform string) { + // The arch selector is independent of the limits; an explicit platform: forces + // the pull to that arch (spec 18) and overrides any template-declared value. + if platform != "" { + out["platform"] = platform + } + + cpus := "" + memMB := memoryMB + reserveMB := 0 + pids := 0 + if res != nil { + if res.CPUs != "" { + cpus = res.CPUs + } + if res.MemoryMB > 0 { + memMB = res.MemoryMB + } + reserveMB = res.MemoryReserveMB + pids = res.PidsLimit + } + + limits := map[string]any{} + reservations := map[string]any{} + if cpus != "" { + out["cpus"] = cpus + limits["cpus"] = cpus + } + if memMB > 0 { + b := bytesFromMB(memMB) + out["mem_limit"] = b + limits["memory"] = b + } + if pids > 0 { + // compose-go/v2 cross-validates pids_limit against deploy.resources.limits.pids + // and rejects distinct values, so the pids cap is dual-written too even though + // the compose spec lists only the top-level pids_limit for it. + out["pids_limit"] = pids + limits["pids"] = pids + } + if reserveMB > 0 { + reservations["memory"] = bytesFromMB(reserveMB) + } + + if len(limits) == 0 && len(reservations) == 0 { + return + } + resources := map[string]any{} + if len(limits) > 0 { + resources["limits"] = limits + } + if len(reservations) > 0 { + resources["reservations"] = reservations + } + // Merge into any template-provided deploy block rather than clobbering it. + deploy, _ := out["deploy"].(map[string]any) + if deploy == nil { + deploy = map[string]any{} + } + deploy["resources"] = resources + out["deploy"] = deploy +} + +// bytesFromMB renders a mebibyte quantity as a canonical byte-count string. +// compose-go requires memory as a string; a fixed bytes rendering (768M → +// "805306368") keeps the generated doc byte-stable across runs (spec 18). +func bytesFromMB(mb int) string { + return strconv.FormatInt(int64(mb)*1024*1024, 10) +} + // projectEnv computes the final environment map for a project service: the // template's own env, then env.raw, then env.prefixed, then env.import. Secret // import attrs become valueless keys (nil) — the §7.5 coupling. All values are diff --git a/internal/generate/resources_test.go b/internal/generate/resources_test.go new file mode 100644 index 0000000..f33b36b --- /dev/null +++ b/internal/generate/resources_test.go @@ -0,0 +1,138 @@ +package generate + +import ( + "testing" + + "github.com/goccy/go-yaml" +) + +// composeService parses a stack's compose bytes and returns one service's map. +func composeService(t *testing.T, compose []byte, name string) map[string]any { + t.Helper() + var doc map[string]any + if err := yaml.Unmarshal(compose, &doc); err != nil { + t.Fatalf("unmarshal compose: %v", err) + } + svcs, ok := doc["services"].(map[string]any) + if !ok { + t.Fatalf("compose has no services map") + } + svc, ok := svcs[name].(map[string]any) + if !ok { + t.Fatalf("service %q not found in compose:\n%s", name, compose) + } + return svc +} + +// TestResourceLimitsDualWrite — a service declaring cpus + memory (via the +// memoryMB shorthand) + reservation + pids emits BOTH the deploy.resources block +// and the legacy top-level knobs, with agreeing canonical byte values (spec 18). +func TestResourceLimitsDualWrite(t *testing.T) { + g, _ := newGen(t) + svc := composeService(t, mustProject(t, g, "api").Compose, "api") + + // Legacy top-level knobs. + if got := svc["cpus"]; got != 1.5 { + t.Errorf("top-level cpus = %v, want 1.5", got) + } + if got := svc["mem_limit"]; got != "805306368" { // 768 MiB + t.Errorf("mem_limit = %v, want 805306368", got) + } + if got := svc["pids_limit"]; got != uint64(512) && got != 512 { + t.Errorf("pids_limit = %v (%T), want 512", got, got) + } + + // deploy.resources block. + deploy, ok := svc["deploy"].(map[string]any) + if !ok { + t.Fatalf("api service has no deploy block: %v", svc["deploy"]) + } + res := deploy["resources"].(map[string]any) + limits := res["limits"].(map[string]any) + if limits["cpus"] != 1.5 { + t.Errorf("limits.cpus = %v, want 1.5", limits["cpus"]) + } + if limits["memory"] != "805306368" { + t.Errorf("limits.memory = %v, want 805306368 (must agree with mem_limit)", limits["memory"]) + } + reservations := res["reservations"].(map[string]any) + if reservations["memory"] != "268435456" { // 256 MiB + t.Errorf("reservations.memory = %v, want 268435456", reservations["memory"]) + } +} + +// TestPlatformPassthrough — a service-declared platform: is emitted verbatim to +// the compose service (spec 18 multi-arch selector). +func TestPlatformPassthrough(t *testing.T) { + g, _ := newGen(t) + svc := composeService(t, mustProject(t, g, "api").Compose, "api") + if got := svc["platform"]; got != "linux/amd64" { + t.Errorf("platform = %v, want linux/amd64", got) + } +} + +// TestNoLimitsNoDeployBlock — a service without a resources block or memoryMB +// emits neither a deploy block nor any legacy limit knob (no spurious diff). +func TestNoLimitsNoDeployBlock(t *testing.T) { + g, _ := newGen(t) + svc := composeService(t, mustProject(t, g, "web").Compose, "web") + for _, k := range []string{"deploy", "cpus", "mem_limit", "pids_limit", "platform"} { + if _, ok := svc[k]; ok { + t.Errorf("web service unexpectedly emitted %q: %v", k, svc[k]) + } + } +} + +// TestSharedResourceLimits — shared-stack services honor workspace.shared.. +// resources the same way project services do (spec 18). +func TestSharedResourceLimits(t *testing.T) { + g, _ := newGen(t) + shared, err := g.GenerateShared() + if err != nil { + t.Fatal(err) + } + pg := composeService(t, shared.Compose, "postgres") + if pg["mem_limit"] != "1073741824" { // 1024 MiB + t.Errorf("postgres mem_limit = %v, want 1073741824", pg["mem_limit"]) + } + deploy := pg["deploy"].(map[string]any) + limits := deploy["resources"].(map[string]any)["limits"].(map[string]any) + // `cpus: 2` decodes as an integer scalar; compare numerically not by type. + if v := scalarString(limits["cpus"]); v != "2" { + t.Errorf("postgres limits.cpus = %v, want 2", limits["cpus"]) + } + // redis declares no resources → no deploy block. + redis := composeService(t, shared.Compose, "redis") + if _, ok := redis["deploy"]; ok { + t.Errorf("redis unexpectedly emitted a deploy block: %v", redis["deploy"]) + } +} + +// TestBytesFromMB — the canonical mebibyte→bytes rendering the dual-write derives +// both spellings from (must be exact for compose-go agreement + determinism). +func TestBytesFromMB(t *testing.T) { + cases := []struct { + mb int + want string + }{ + {768, "805306368"}, + {1024, "1073741824"}, + {256, "268435456"}, + {1, "1048576"}, + } + for _, c := range cases { + if got := bytesFromMB(c.mb); got != c.want { + t.Errorf("bytesFromMB(%d) = %s, want %s", c.mb, got, c.want) + } + } +} + +// TestApplyResourcesEmptyIsNoop — applyResources with no limits and no platform +// leaves the service map untouched (the "no spurious diff" invariant, unit level). +func TestApplyResourcesEmptyIsNoop(t *testing.T) { + out := map[string]any{"image": "nginx:latest"} + applyResources(out, nil, 0, "") + if len(out) != 1 { + t.Errorf("applyResources(empty) mutated the service map: %v", out) + } +} diff --git a/internal/generate/testdata/golden/devstack-api.docker-compose.yaml b/internal/generate/testdata/golden/devstack-api.docker-compose.yaml index db375d3..5c38f03 100644 --- a/internal/generate/testdata/golden/devstack-api.docker-compose.yaml +++ b/internal/generate/testdata/golden/devstack-api.docker-compose.yaml @@ -7,8 +7,17 @@ services: args: PHP_VERSION: "8.3" WITH_COMPOSER: "true" + cpus: 1.5 command: - php-fpm + deploy: + resources: + limits: + cpus: 1.5 + memory: "805306368" + pids: 512 + reservations: + memory: "268435456" entrypoint: - /entrypoint.sh environment: @@ -40,9 +49,12 @@ services: com.devstack.project: api com.devstack.service: api com.devstack.workspace: acme + mem_limit: "805306368" networks: default: null devstack_shared: null + pids_limit: 512 + platform: linux/amd64 restart: unless-stopped networks: default: diff --git a/internal/generate/testdata/golden/devstack-shared.docker-compose.yaml b/internal/generate/testdata/golden/devstack-shared.docker-compose.yaml index 005056b..07832f8 100644 --- a/internal/generate/testdata/golden/devstack-shared.docker-compose.yaml +++ b/internal/generate/testdata/golden/devstack-shared.docker-compose.yaml @@ -34,6 +34,14 @@ services: target: /data volume: {} postgres: + cpus: 2 + deploy: + resources: + limits: + cpus: 2 + memory: "1073741824" + reservations: + memory: "536870912" environment: POSTGRES_DB: devstack POSTGRES_PASSWORD: devstack @@ -50,6 +58,7 @@ services: com.devstack.managed: "true" com.devstack.shared: postgres com.devstack.workspace: acme + mem_limit: "1073741824" networks: devstack_shared: aliases: diff --git a/internal/profile/profile.go b/internal/profile/profile.go index bfd8c5a..dc37b7a 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -81,16 +81,17 @@ type Budget struct { 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. +// CheckBudget sums the active services' effective memory limit (resources.memoryMB +// or the memoryMB shorthand, spec 18) 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 memory limit 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 + mb := p.Services[sname].EffectiveMemoryMB() if mb <= 0 { continue }