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
51 changes: 45 additions & 6 deletions internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -130,10 +145,34 @@ type Service struct {
Uses []string `yaml:"uses"` // consume SHARED services: workspace.shared.<name>
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
Expand Down
127 changes: 127 additions & 0 deletions internal/config/resource_limits_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
5 changes: 5 additions & 0 deletions internal/config/testdata/valid/services/api/devstack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion internal/config/testdata/valid/workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions internal/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions internal/generate/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"maps"
"sort"
"strconv"
"strings"

"github.com/compose-spec/compose-go/v2/loader"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.<svc>.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
Expand Down
Loading
Loading