diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 72bbacc..0cddef8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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. diff --git a/internal/config/model.go b/internal/config/model.go index 014dc3a..11f877e 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -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 @@ -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 } diff --git a/internal/config/testdata/valid/services/api/devstack.yaml b/internal/config/testdata/valid/services/api/devstack.yaml index 4ce1a3d..a4ba81b 100644 --- a/internal/config/testdata/valid/services/api/devstack.yaml +++ b/internal/config/testdata/valid/services/api/devstack.yaml @@ -5,6 +5,7 @@ services: api: template: php.laravel.nginx params: { phpVersion: "8.3" } + memoryMB: 768 uses: - workspace.shared.postgres - workspace.shared.redis diff --git a/internal/config/testdata/valid/workspace.yaml b/internal/config/testdata/valid/workspace.yaml index 35a09ef..987e8ad 100644 --- a/internal/config/testdata/valid/workspace.yaml +++ b/internal/config/testdata/valid/workspace.yaml @@ -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"] } diff --git a/internal/config/validate.go b/internal/config/validate.go index 668446d..ac6da07 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -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).