Skip to content

Commit 1c77d44

Browse files
feat(config): X1 — config completion: memoryMB/memoryBudgetMB + group/profile validation (spec 12/18) (#27)
Fill the remaining declarative schema + its semantic validation: - Service.MemoryMB and Workspace.MemoryBudgetMB (spec 12/18) — the per-service budget hint + the workspace budget the `up` warning will key off (the summation/probe is X4/spec 18; this wires the schema so it drops in without a bump). Group.MemoryHintMB, DependsOn, Groups, DefaultProfile, and all hook points already landed (C3a + existing model), completing the config surface. - validateProfiles: every `groups.<g>.services` entry must reference a real service (bare names across the workspace), and `defaultProfile` (if set) must name a defined group or the reserved "all" — both positioned to file:line:col with a did-you-mean. The valid testdata now exercises defaultProfile/groups/memoryBudgetMB/memoryMB; generate ignores them so the golden + determinism are unchanged. Tests: LoadValid asserts the new fields; negative tests for an unknown group service, an unknown defaultProfile group, and the reserved `all`. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 59e383c commit 1c77d44

5 files changed

Lines changed: 127 additions & 0 deletions

File tree

internal/config/config_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,79 @@ func TestLoadValid(t *testing.T) {
8383
if got := len(m.Workspace.Hooks.PreUp); got != 1 || m.Workspace.Hooks.PreUp[0].Name != "banner" {
8484
t.Errorf("workspace preUp = %+v, want one 'banner' hook", m.Workspace.Hooks.PreUp)
8585
}
86+
87+
// spec 12/18 — profiles/groups + memory budget hints parse.
88+
if m.Workspace.DefaultProfile != "core" {
89+
t.Errorf("defaultProfile = %q, want core", m.Workspace.DefaultProfile)
90+
}
91+
if g, ok := m.Workspace.Groups["frontend"]; !ok || len(g.Services) != 1 || g.MemoryHintMB != 1024 {
92+
t.Errorf("group frontend = %+v", m.Workspace.Groups["frontend"])
93+
}
94+
if m.Workspace.MemoryBudgetMB != 4096 {
95+
t.Errorf("memoryBudgetMB = %d, want 4096", m.Workspace.MemoryBudgetMB)
96+
}
97+
if apiSvc.MemoryMB != 768 {
98+
t.Errorf("api.api memoryMB = %d, want 768", apiSvc.MemoryMB)
99+
}
100+
}
101+
102+
func TestGroupUnknownService(t *testing.T) {
103+
root := writeTree(t, map[string]string{
104+
"workspace.yaml": `apiVersion: devstack/v1
105+
kind: Workspace
106+
name: acme
107+
groups:
108+
core: { services: [ghost] }
109+
shared:
110+
postgres: { template: postgres }
111+
projects:
112+
- { name: api, path: api }
113+
`,
114+
"api/devstack.yaml": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api: { template: t }\n",
115+
})
116+
_, err := LoadAt(root)
117+
if err == nil || !strings.Contains(err.Error(), "ghost") {
118+
t.Fatalf("want an unknown-group-service error naming ghost, got %v", err)
119+
}
120+
}
121+
122+
func TestDefaultProfileUnknownGroup(t *testing.T) {
123+
root := writeTree(t, map[string]string{
124+
"workspace.yaml": `apiVersion: devstack/v1
125+
kind: Workspace
126+
name: acme
127+
defaultProfile: nope
128+
groups:
129+
core: { services: [api] }
130+
shared:
131+
postgres: { template: postgres }
132+
projects:
133+
- { name: api, path: api }
134+
`,
135+
"api/devstack.yaml": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api: { template: t }\n",
136+
})
137+
_, err := LoadAt(root)
138+
if err == nil || !strings.Contains(err.Error(), "defaultProfile") {
139+
t.Fatalf("want a defaultProfile error, got %v", err)
140+
}
141+
}
142+
143+
func TestDefaultProfileAllReserved(t *testing.T) {
144+
root := writeTree(t, map[string]string{
145+
"workspace.yaml": `apiVersion: devstack/v1
146+
kind: Workspace
147+
name: acme
148+
defaultProfile: all
149+
shared:
150+
postgres: { template: postgres }
151+
projects:
152+
- { name: api, path: api }
153+
`,
154+
"api/devstack.yaml": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api: { template: t }\n",
155+
})
156+
if _, err := LoadAt(root); err != nil {
157+
t.Fatalf("defaultProfile: all is reserved and valid without a group, got %v", err)
158+
}
86159
}
87160

88161
// projectWith wraps a services: block in the valid workspace+project envelope.

internal/config/model.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type Workspace struct {
2828
Profiles Profiles `yaml:"profiles"`
2929
DefaultProfile string `yaml:"defaultProfile"` // spec 12 — slice activated by `up` with no --profile
3030
Groups map[string]Group `yaml:"groups"` // spec 12 — workspace-level service slices
31+
MemoryBudgetMB int `yaml:"memoryBudgetMB"` // spec 12/18 — warn when active services' memoryMB sum exceeds this
3132
Secrets Secrets `yaml:"secrets"`
3233
Network Network `yaml:"network"`
3334
Hooks Hooks `yaml:"hooks"` // spec 11 — workspace-scope lifecycle hooks
@@ -113,6 +114,7 @@ type Service struct {
113114
Env Env `yaml:"env"`
114115
Ports map[string]int `yaml:"ports"`
115116
Profiles []string `yaml:"profiles"` // spec 12 — Compose profile membership tags
117+
MemoryMB int `yaml:"memoryMB"` // spec 12/18 — per-service budget hint (reserved)
116118
Healthcheck *Healthcheck `yaml:"healthcheck"` // spec 10 — readiness probe (nil = none)
117119
DependsOn []DependsOn `yaml:"dependsOn" validate:"dive"` // spec 10 — ordering edges
118120
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ services:
55
api:
66
template: php.laravel.nginx
77
params: { phpVersion: "8.3" }
8+
memoryMB: 768
89
uses:
910
- workspace.shared.postgres
1011
- workspace.shared.redis

internal/config/testdata/valid/workspace.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ kind: Workspace
33
name: acme
44
aliases: [rq, uranus]
55
profiles: { default: dev }
6+
defaultProfile: core
7+
groups:
8+
core: { services: [api] }
9+
frontend: { services: [web], memoryHintMB: 1024 }
10+
memoryBudgetMB: 4096
611
hooks:
712
preUp:
813
- { name: banner, run: host, command: ["true"] }

internal/config/validate.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,55 @@ func validateModel(m *Model, ws *source, projSrc map[string]*source) error {
6161
if err := validateRefs(m, projSrc); err != nil {
6262
return err
6363
}
64+
if err := validateProfiles(m, ws); err != nil {
65+
return err
66+
}
6467
return detectCycles(m)
6568
}
6669

70+
// validateProfiles checks the spec-12 service-slice config: every group's
71+
// services reference a real service, and defaultProfile (if set) names a defined
72+
// group (or the reserved "all"). Positioned to the workspace file.
73+
func validateProfiles(m *Model, ws *source) error {
74+
services := allServiceNames(m)
75+
for _, gname := range sortedKeys(m.Workspace.Groups) {
76+
for i, svc := range m.Workspace.Groups[gname].Services {
77+
if !services[svc] {
78+
return ws.errAt(fmt.Sprintf("$.groups.%s.services[%d]", gname, i),
79+
"group %q references unknown service %q%s", gname, svc, suggest(svc, sortedSet(services)))
80+
}
81+
}
82+
}
83+
if dp := m.Workspace.DefaultProfile; dp != "" && dp != "all" {
84+
if _, ok := m.Workspace.Groups[dp]; !ok {
85+
return ws.errAt("$.defaultProfile",
86+
"defaultProfile %q is not a defined group%s", dp, suggest(dp, sortedKeys(m.Workspace.Groups)))
87+
}
88+
}
89+
return nil
90+
}
91+
92+
// allServiceNames is the set of every service name across all projects (group
93+
// slices reference bare service names, spec 12).
94+
func allServiceNames(m *Model) map[string]bool {
95+
out := map[string]bool{}
96+
for _, p := range m.Projects {
97+
for sname := range p.Services {
98+
out[sname] = true
99+
}
100+
}
101+
return out
102+
}
103+
104+
func sortedSet(set map[string]bool) []string {
105+
out := make([]string, 0, len(set))
106+
for k := range set {
107+
out = append(out, k)
108+
}
109+
sort.Strings(out)
110+
return out
111+
}
112+
67113
// formatStructErr renders validator.ValidationErrors as a file-scoped, sorted,
68114
// one-per-line message (positions for structural errors are a later refinement;
69115
// cross-ref errors below carry line:col).

0 commit comments

Comments
 (0)