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
73 changes: 73 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,79 @@ func TestLoadValid(t *testing.T) {
if got := len(m.Workspace.Hooks.PreUp); got != 1 || m.Workspace.Hooks.PreUp[0].Name != "banner" {
t.Errorf("workspace preUp = %+v, want one 'banner' hook", m.Workspace.Hooks.PreUp)
}

// spec 12/18 — profiles/groups + memory budget hints parse.
if m.Workspace.DefaultProfile != "core" {
t.Errorf("defaultProfile = %q, want core", m.Workspace.DefaultProfile)
}
if g, ok := m.Workspace.Groups["frontend"]; !ok || len(g.Services) != 1 || g.MemoryHintMB != 1024 {
t.Errorf("group frontend = %+v", m.Workspace.Groups["frontend"])
}
if m.Workspace.MemoryBudgetMB != 4096 {
t.Errorf("memoryBudgetMB = %d, want 4096", m.Workspace.MemoryBudgetMB)
}
if apiSvc.MemoryMB != 768 {
t.Errorf("api.api memoryMB = %d, want 768", apiSvc.MemoryMB)
}
}

func TestGroupUnknownService(t *testing.T) {
root := writeTree(t, map[string]string{
"workspace.yaml": `apiVersion: devstack/v1
kind: Workspace
name: acme
groups:
core: { services: [ghost] }
shared:
postgres: { template: postgres }
projects:
- { name: api, path: api }
`,
"api/devstack.yaml": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api: { template: t }\n",
})
_, err := LoadAt(root)
if err == nil || !strings.Contains(err.Error(), "ghost") {
t.Fatalf("want an unknown-group-service error naming ghost, got %v", err)
}
}

func TestDefaultProfileUnknownGroup(t *testing.T) {
root := writeTree(t, map[string]string{
"workspace.yaml": `apiVersion: devstack/v1
kind: Workspace
name: acme
defaultProfile: nope
groups:
core: { services: [api] }
shared:
postgres: { template: postgres }
projects:
- { name: api, path: api }
`,
"api/devstack.yaml": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api: { template: t }\n",
})
_, err := LoadAt(root)
if err == nil || !strings.Contains(err.Error(), "defaultProfile") {
t.Fatalf("want a defaultProfile error, got %v", err)
}
}

func TestDefaultProfileAllReserved(t *testing.T) {
root := writeTree(t, map[string]string{
"workspace.yaml": `apiVersion: devstack/v1
kind: Workspace
name: acme
defaultProfile: all
shared:
postgres: { template: postgres }
projects:
- { name: api, path: api }
`,
"api/devstack.yaml": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api: { template: t }\n",
})
if _, err := LoadAt(root); err != nil {
t.Fatalf("defaultProfile: all is reserved and valid without a group, got %v", err)
}
}

// projectWith wraps a services: block in the valid workspace+project envelope.
Expand Down
2 changes: 2 additions & 0 deletions internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Workspace struct {
Profiles Profiles `yaml:"profiles"`
DefaultProfile string `yaml:"defaultProfile"` // spec 12 — slice activated by `up` with no --profile
Groups map[string]Group `yaml:"groups"` // spec 12 — workspace-level service slices
MemoryBudgetMB int `yaml:"memoryBudgetMB"` // spec 12/18 — warn when active services' memoryMB sum exceeds this
Secrets Secrets `yaml:"secrets"`
Network Network `yaml:"network"`
Hooks Hooks `yaml:"hooks"` // spec 11 — workspace-scope lifecycle hooks
Expand Down Expand Up @@ -113,6 +114,7 @@ type Service struct {
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
}
Expand Down
1 change: 1 addition & 0 deletions internal/config/testdata/valid/services/api/devstack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ services:
api:
template: php.laravel.nginx
params: { phpVersion: "8.3" }
memoryMB: 768
uses:
- workspace.shared.postgres
- workspace.shared.redis
Expand Down
5 changes: 5 additions & 0 deletions internal/config/testdata/valid/workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ kind: Workspace
name: acme
aliases: [rq, uranus]
profiles: { default: dev }
defaultProfile: core
groups:
core: { services: [api] }
frontend: { services: [web], memoryHintMB: 1024 }
memoryBudgetMB: 4096
hooks:
preUp:
- { name: banner, run: host, command: ["true"] }
Expand Down
46 changes: 46 additions & 0 deletions internal/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,55 @@ func validateModel(m *Model, ws *source, projSrc map[string]*source) error {
if err := validateRefs(m, projSrc); err != nil {
return err
}
if err := validateProfiles(m, ws); err != nil {
return err
}
return detectCycles(m)
}

// validateProfiles checks the spec-12 service-slice config: every group's
// services reference a real service, and defaultProfile (if set) names a defined
// group (or the reserved "all"). Positioned to the workspace file.
func validateProfiles(m *Model, ws *source) error {
services := allServiceNames(m)
for _, gname := range sortedKeys(m.Workspace.Groups) {
for i, svc := range m.Workspace.Groups[gname].Services {
if !services[svc] {
return ws.errAt(fmt.Sprintf("$.groups.%s.services[%d]", gname, i),
"group %q references unknown service %q%s", gname, svc, suggest(svc, sortedSet(services)))
}
}
}
if dp := m.Workspace.DefaultProfile; dp != "" && dp != "all" {
if _, ok := m.Workspace.Groups[dp]; !ok {
return ws.errAt("$.defaultProfile",
"defaultProfile %q is not a defined group%s", dp, suggest(dp, sortedKeys(m.Workspace.Groups)))
}
}
return nil
}

// allServiceNames is the set of every service name across all projects (group
// slices reference bare service names, spec 12).
func allServiceNames(m *Model) map[string]bool {
out := map[string]bool{}
for _, p := range m.Projects {
for sname := range p.Services {
out[sname] = true
}
}
return out
}

func sortedSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}

// formatStructErr renders validator.ValidationErrors as a file-scoped, sorted,
// one-per-line message (positions for structural errors are a later refinement;
// cross-ref errors below carry line:col).
Expand Down
Loading