Skip to content

Commit 27f7cc7

Browse files
feat(generate): compose resource limits + multi-arch platform (spec 18) (#99)
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) <noreply@anthropic.com>
1 parent eea55d4 commit 27f7cc7

10 files changed

Lines changed: 456 additions & 12 deletions

File tree

internal/config/model.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,24 @@ type Tunnel struct {
8484

8585
// SharedSvc is one shared infrastructure service (postgres/redis/minio/...),
8686
// rendered from a template (spec 03). Reached by alias DNS, ref-counted.
87+
//
88+
// Resources/Platform (spec 18) apply the same CPU/memory limits + arch selector
89+
// to the shared stack that project services get; changing a shared limit is a
90+
// stateful-service restart, gated behind the same explicit confirm as any shared
91+
// recreate (spec 03) — never silent.
8792
type SharedSvc struct {
88-
Template string `yaml:"template" validate:"required"`
89-
Params map[string]any `yaml:"params"`
93+
Template string `yaml:"template" validate:"required"`
94+
Params map[string]any `yaml:"params"`
95+
Resources *Resources `yaml:"resources"` // spec 18 — CPU/memory limits
96+
Platform string `yaml:"platform" validate:"omitempty,platform"` // spec 18 — e.g. linux/amd64
97+
}
98+
99+
// EffectiveMemoryMB is the shared service's hard memory limit in MB (0 = unset).
100+
func (s SharedSvc) EffectiveMemoryMB() int {
101+
if s.Resources != nil {
102+
return s.Resources.MemoryMB
103+
}
104+
return 0
90105
}
91106

92107
// ProjectRef points workspace.yaml at a repo containing a devstack.yaml.
@@ -130,10 +145,34 @@ type Service struct {
130145
Uses []string `yaml:"uses"` // consume SHARED services: workspace.shared.<name>
131146
Env Env `yaml:"env"`
132147
Ports map[string]int `yaml:"ports"`
133-
Profiles []string `yaml:"profiles"` // spec 12 — Compose profile membership tags
134-
MemoryMB int `yaml:"memoryMB"` // spec 12/18 — per-service budget hint (reserved)
135-
Healthcheck *Healthcheck `yaml:"healthcheck"` // spec 10 — readiness probe (nil = none)
136-
DependsOn []DependsOn `yaml:"dependsOn" validate:"dive"` // spec 10 — ordering edges
148+
Profiles []string `yaml:"profiles"` // spec 12 — Compose profile membership tags
149+
MemoryMB int `yaml:"memoryMB"` // spec 12/18 — budget hint == shorthand for resources.memoryMB
150+
Resources *Resources `yaml:"resources"` // spec 18 — CPU/memory/pids limits (nil = none)
151+
Platform string `yaml:"platform" validate:"omitempty,platform"` // spec 18 — arch selector, e.g. linux/amd64
152+
Healthcheck *Healthcheck `yaml:"healthcheck"` // spec 10 — readiness probe (nil = none)
153+
DependsOn []DependsOn `yaml:"dependsOn" validate:"dive"` // spec 10 — ordering edges
154+
}
155+
156+
// Resources is the spec-18 per-service resource-limit block. It lowers to a
157+
// deterministic dual-write in the generated compose (deploy.resources.limits.*
158+
// AND the legacy top-level cpus/mem_limit/pids_limit), so both the deploy-aware
159+
// and non-deploy compose readers honor the same canonical values. All fields are
160+
// optional; an omitted field emits nothing. See docs/specs/18.
161+
type Resources struct {
162+
CPUs string `yaml:"cpus" validate:"omitempty,cpus"` // fractional cores, e.g. "1.5"
163+
MemoryMB int `yaml:"memoryMB" validate:"omitempty,gte=0"` // hard memory limit
164+
MemoryReserveMB int `yaml:"memoryReserveMB" validate:"omitempty,gte=0"` // soft reservation (scheduling hint)
165+
PidsLimit int `yaml:"pidsLimit" validate:"omitempty,gte=0"` // max PIDs
166+
}
167+
168+
// EffectiveMemoryMB is the service's hard memory limit in MB: resources.memoryMB
169+
// when set, else the top-level memoryMB shorthand (spec 18). 0 means unset — used
170+
// for both the emitted mem_limit and the budget summation so the two never drift.
171+
func (s Service) EffectiveMemoryMB() int {
172+
if s.Resources != nil && s.Resources.MemoryMB > 0 {
173+
return s.Resources.MemoryMB
174+
}
175+
return s.MemoryMB
137176
}
138177

139178
// Healthcheck declares a service's readiness probe (spec 10). It compiles to
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package config
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// resLimitsProject wraps a service body into a minimal but valid two-file tree.
9+
func resLimitsProject(t *testing.T, svcBody string) (*Model, error) {
10+
t.Helper()
11+
ws := `apiVersion: devstack/v1
12+
kind: Workspace
13+
name: acme
14+
shared:
15+
postgres: { template: postgres }
16+
projects:
17+
- { name: api, path: api }
18+
`
19+
proj := "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api:\n template: t\n" + svcBody
20+
root := writeTree(t, map[string]string{"workspace.yaml": ws, "api/devstack.yaml": proj})
21+
return LoadAt(root)
22+
}
23+
24+
// TestResourceLimitsParse — the spec-18 resources block + platform selector parse
25+
// onto a project service, and EffectiveMemoryMB prefers resources.memoryMB.
26+
func TestResourceLimitsParse(t *testing.T) {
27+
m, err := resLimitsProject(t, ` platform: linux/amd64
28+
resources:
29+
cpus: "1.5"
30+
memoryMB: 512
31+
memoryReserveMB: 256
32+
pidsLimit: 128
33+
`)
34+
if err != nil {
35+
t.Fatalf("LoadAt: %v", err)
36+
}
37+
svc := m.Projects["api"].Services["api"]
38+
if svc.Platform != "linux/amd64" {
39+
t.Errorf("platform = %q, want linux/amd64", svc.Platform)
40+
}
41+
if svc.Resources == nil {
42+
t.Fatal("resources block did not parse")
43+
}
44+
if svc.Resources.CPUs != "1.5" || svc.Resources.MemoryMB != 512 ||
45+
svc.Resources.MemoryReserveMB != 256 || svc.Resources.PidsLimit != 128 {
46+
t.Errorf("resources = %+v", *svc.Resources)
47+
}
48+
if got := svc.EffectiveMemoryMB(); got != 512 {
49+
t.Errorf("EffectiveMemoryMB = %d, want 512 (resources.memoryMB wins)", got)
50+
}
51+
}
52+
53+
// TestEffectiveMemoryShorthand — with no resources.memoryMB, the top-level
54+
// memoryMB shorthand is the effective limit; with neither, it is 0.
55+
func TestEffectiveMemoryShorthand(t *testing.T) {
56+
m, err := resLimitsProject(t, " memoryMB: 768\n")
57+
if err != nil {
58+
t.Fatalf("LoadAt: %v", err)
59+
}
60+
if got := m.Projects["api"].Services["api"].EffectiveMemoryMB(); got != 768 {
61+
t.Errorf("EffectiveMemoryMB = %d, want 768 (shorthand)", got)
62+
}
63+
64+
m2, err := resLimitsProject(t, "")
65+
if err != nil {
66+
t.Fatalf("LoadAt: %v", err)
67+
}
68+
if got := m2.Projects["api"].Services["api"].EffectiveMemoryMB(); got != 0 {
69+
t.Errorf("EffectiveMemoryMB = %d, want 0 (unset)", got)
70+
}
71+
}
72+
73+
// TestPlatformValidation — a malformed platform selector is rejected with a
74+
// file-scoped error; well-formed selectors pass.
75+
func TestPlatformValidation(t *testing.T) {
76+
if _, err := resLimitsProject(t, " platform: not-a-platform\n"); err == nil ||
77+
!strings.Contains(err.Error(), "platform") {
78+
t.Fatalf("want a platform validation error, got %v", err)
79+
}
80+
for _, p := range []string{"linux/amd64", "linux/arm64/v8", "darwin/arm64"} {
81+
if _, err := resLimitsProject(t, " platform: "+p+"\n"); err != nil {
82+
t.Errorf("platform %q should be valid, got %v", p, err)
83+
}
84+
}
85+
}
86+
87+
// TestCPUsValidation — a non-numeric or non-positive cpus quantity is rejected.
88+
func TestCPUsValidation(t *testing.T) {
89+
for _, bad := range []string{"lots", "0", "-1"} {
90+
if _, err := resLimitsProject(t, " resources: { cpus: \""+bad+"\" }\n"); err == nil ||
91+
!strings.Contains(err.Error(), "cpu") {
92+
t.Errorf("cpus %q should be rejected, got %v", bad, err)
93+
}
94+
}
95+
if _, err := resLimitsProject(t, " resources: { cpus: \"1.5\" }\n"); err != nil {
96+
t.Errorf("cpus 1.5 should be valid, got %v", err)
97+
}
98+
}
99+
100+
// TestSharedResourcesParse — shared services accept the same resources/platform
101+
// knobs from workspace.yaml (spec 18, shared stack).
102+
func TestSharedResourcesParse(t *testing.T) {
103+
ws := `apiVersion: devstack/v1
104+
kind: Workspace
105+
name: acme
106+
shared:
107+
postgres:
108+
template: postgres
109+
platform: linux/amd64
110+
resources: { cpus: "2", memoryMB: 1024 }
111+
projects:
112+
- { name: api, path: api }
113+
`
114+
proj := "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api: { template: t }\n"
115+
root := writeTree(t, map[string]string{"workspace.yaml": ws, "api/devstack.yaml": proj})
116+
m, err := LoadAt(root)
117+
if err != nil {
118+
t.Fatalf("LoadAt: %v", err)
119+
}
120+
pg := m.Workspace.Shared["postgres"]
121+
if pg.Platform != "linux/amd64" {
122+
t.Errorf("shared platform = %q, want linux/amd64", pg.Platform)
123+
}
124+
if pg.EffectiveMemoryMB() != 1024 {
125+
t.Errorf("shared EffectiveMemoryMB = %d, want 1024", pg.EffectiveMemoryMB())
126+
}
127+
}

internal/config/testdata/valid/services/api/devstack.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ services:
66
template: php.laravel.nginx
77
params: { phpVersion: "8.3" }
88
memoryMB: 768
9+
platform: linux/amd64
10+
resources:
11+
cpus: "1.5"
12+
memoryReserveMB: 256
13+
pidsLimit: 512
914
uses:
1015
- workspace.shared.postgres
1116
- workspace.shared.redis

internal/config/testdata/valid/workspace.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ hooks:
1212
preUp:
1313
- { name: banner, run: host, command: ["true"] }
1414
shared:
15-
postgres: { template: postgres, params: { version: "16" } }
15+
postgres:
16+
template: postgres
17+
params: { version: "16" }
18+
resources: { cpus: "2", memoryMB: 1024, memoryReserveMB: 512 }
1619
redis: { template: redis, params: { version: "7" } }
1720
minio: { template: minio }
1821
projects:

internal/config/validate.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"regexp"
77
"sort"
8+
"strconv"
89
"strings"
910
"time"
1011

@@ -33,9 +34,23 @@ func newValidator() *validator.Validate {
3334
_, err := time.ParseDuration(fl.Field().String())
3435
return err == nil
3536
})
37+
// cpus: a positive fractional-core string ("1.5", "2"). Emitted verbatim into
38+
// the compose cpus/limits.cpus fields (spec 18); must parse as a float > 0.
39+
_ = v.RegisterValidation("cpus", func(fl validator.FieldLevel) bool {
40+
f, err := strconv.ParseFloat(fl.Field().String(), 64)
41+
return err == nil && f > 0
42+
})
43+
// platform: an os/arch[/variant] selector ("linux/amd64", "linux/arm64/v8").
44+
_ = v.RegisterValidation("platform", func(fl validator.FieldLevel) bool {
45+
return platformRE.MatchString(fl.Field().String())
46+
})
3647
return v
3748
}
3849

50+
// platformRE matches a compose `platform:` selector: os/arch with an optional
51+
// variant (e.g. linux/amd64, linux/arm64/v8).
52+
var platformRE = regexp.MustCompile(`^[a-z0-9]+/[a-z0-9]+(/[a-z0-9]+)?$`)
53+
3954
// structValidate runs validator/v10, recovering from the panic it raises on a
4055
// malformed tag (DECISIONS D16) so a tag bug never crashes the CLI.
4156
func structValidate(v any) (err error) {
@@ -193,6 +208,12 @@ func describeFieldError(fe validator.FieldError) string {
193208
return fmt.Sprintf("%s = %q must be one of: %s", field, fe.Value(), strings.ReplaceAll(fe.Param(), " ", ", "))
194209
case "duration":
195210
return fmt.Sprintf("%s = %q is not a valid duration (e.g. \"5s\", \"1m30s\")", field, fe.Value())
211+
case "cpus":
212+
return fmt.Sprintf("%s = %q is not a valid cpu quantity (a positive number of cores, e.g. \"1.5\")", field, fe.Value())
213+
case "platform":
214+
return fmt.Sprintf("%s = %q is not a valid platform (expected os/arch, e.g. \"linux/amd64\")", field, fe.Value())
215+
case "gte":
216+
return fmt.Sprintf("%s must be >= %s", field, fe.Param())
196217
case "min":
197218
return fmt.Sprintf("%s must have at least %s element(s)", field, fe.Param())
198219
default:

internal/generate/compose.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"maps"
77
"sort"
8+
"strconv"
89
"strings"
910

1011
"github.com/compose-spec/compose-go/v2/loader"
@@ -106,6 +107,11 @@ func buildProjectService(res *graphResolver, m *config.Model, project, service s
106107
out["expose"] = exp
107108
}
108109

110+
// spec 18 — CPU/memory/pids limits + arch selector. memoryMB is the shorthand
111+
// for resources.memoryMB; the effective value drives both the emitted limit and
112+
// the up/doctor budget sum so the two never drift.
113+
applyResources(out, svc.Resources, svc.MemoryMB, svc.Platform)
114+
109115
// spec 10 — a service-declared healthcheck overrides any template default and
110116
// is lowered to a Compose-native healthcheck: block.
111117
if svc.Healthcheck != nil {
@@ -151,9 +157,92 @@ func buildSharedService(m *config.Model, name string, resolved *template.Resolve
151157
SharedNetwork: map[string]any{"aliases": []any{sharedAlias(name)}},
152158
}
153159
out["labels"] = b.labels(map[string]string{LabelShared: name})
160+
161+
// spec 18 — the same CPU/memory limits + arch selector apply to the shared
162+
// stack; declared in workspace.yaml shared.<svc>.resources.
163+
ss := m.Workspace.Shared[name]
164+
applyResources(out, ss.Resources, 0, ss.Platform)
154165
return out, nil
155166
}
156167

168+
// applyResources lowers a spec-18 resources block + platform selector onto a
169+
// compose service map. Limits are DUAL-WRITTEN from one canonical byte value:
170+
// deploy.resources.limits.* (the spec-blessed path compose v2 honors on plain
171+
// containers) AND the legacy top-level cpus/mem_limit/pids_limit, so both the
172+
// deploy-aware and non-deploy readers see identical values and compose-go/v2's
173+
// cross-field consistency check passes. A service that declares no limits emits
174+
// no deploy block at all (no spurious diff). memoryMB is the shorthand fed as the
175+
// baseline memory limit; an explicit resources.memoryMB overrides it.
176+
func applyResources(out map[string]any, res *config.Resources, memoryMB int, platform string) {
177+
// The arch selector is independent of the limits; an explicit platform: forces
178+
// the pull to that arch (spec 18) and overrides any template-declared value.
179+
if platform != "" {
180+
out["platform"] = platform
181+
}
182+
183+
cpus := ""
184+
memMB := memoryMB
185+
reserveMB := 0
186+
pids := 0
187+
if res != nil {
188+
if res.CPUs != "" {
189+
cpus = res.CPUs
190+
}
191+
if res.MemoryMB > 0 {
192+
memMB = res.MemoryMB
193+
}
194+
reserveMB = res.MemoryReserveMB
195+
pids = res.PidsLimit
196+
}
197+
198+
limits := map[string]any{}
199+
reservations := map[string]any{}
200+
if cpus != "" {
201+
out["cpus"] = cpus
202+
limits["cpus"] = cpus
203+
}
204+
if memMB > 0 {
205+
b := bytesFromMB(memMB)
206+
out["mem_limit"] = b
207+
limits["memory"] = b
208+
}
209+
if pids > 0 {
210+
// compose-go/v2 cross-validates pids_limit against deploy.resources.limits.pids
211+
// and rejects distinct values, so the pids cap is dual-written too even though
212+
// the compose spec lists only the top-level pids_limit for it.
213+
out["pids_limit"] = pids
214+
limits["pids"] = pids
215+
}
216+
if reserveMB > 0 {
217+
reservations["memory"] = bytesFromMB(reserveMB)
218+
}
219+
220+
if len(limits) == 0 && len(reservations) == 0 {
221+
return
222+
}
223+
resources := map[string]any{}
224+
if len(limits) > 0 {
225+
resources["limits"] = limits
226+
}
227+
if len(reservations) > 0 {
228+
resources["reservations"] = reservations
229+
}
230+
// Merge into any template-provided deploy block rather than clobbering it.
231+
deploy, _ := out["deploy"].(map[string]any)
232+
if deploy == nil {
233+
deploy = map[string]any{}
234+
}
235+
deploy["resources"] = resources
236+
out["deploy"] = deploy
237+
}
238+
239+
// bytesFromMB renders a mebibyte quantity as a canonical byte-count string.
240+
// compose-go requires memory as a string; a fixed bytes rendering (768M →
241+
// "805306368") keeps the generated doc byte-stable across runs (spec 18).
242+
func bytesFromMB(mb int) string {
243+
return strconv.FormatInt(int64(mb)*1024*1024, 10)
244+
}
245+
157246
// projectEnv computes the final environment map for a project service: the
158247
// template's own env, then env.raw, then env.prefixed, then env.import. Secret
159248
// import attrs become valueless keys (nil) — the §7.5 coupling. All values are

0 commit comments

Comments
 (0)