diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dbb32b5..72bbacc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -47,6 +47,113 @@ func TestLoadValid(t *testing.T) { if dir := m.ProjectDir("api"); !strings.HasSuffix(dir, filepath.Join("services", "api")) { t.Errorf("ProjectDir(api) = %q, want suffix services/api", dir) } + + // spec 10 — healthcheck + dependsOn parse onto the service. + apiSvc := api.Services["api"] + if apiSvc.Healthcheck == nil { + t.Fatal("api.api healthcheck not parsed") + } + if apiSvc.Healthcheck.Kind != "http" || apiSvc.Healthcheck.Port != 8080 { + t.Errorf("healthcheck = %+v, want kind=http port=8080", apiSvc.Healthcheck) + } + if apiSvc.Healthcheck.StartPeriod != "20s" || apiSvc.Healthcheck.Retries != 12 { + t.Errorf("healthcheck timing = %+v, want startPeriod=20s retries=12", apiSvc.Healthcheck) + } + if got := len(apiSvc.DependsOn); got != 2 { + t.Fatalf("api.api dependsOn = %d, want 2", got) + } + if apiSvc.DependsOn[0].Service != "workspace.shared.postgres" || apiSvc.DependsOn[0].Condition != "healthy" { + t.Errorf("dependsOn[0] = %+v", apiSvc.DependsOn[0]) + } + + // spec 11 — project- and workspace-scope hooks parse. + if got := len(api.Hooks.FirstRun); got != 1 { + t.Fatalf("api firstRun hooks = %d, want 1", got) + } + fr := api.Hooks.FirstRun[0] + if fr.Name != "migrate-and-seed" || fr.Run != "exec" || fr.Service != "api" { + t.Errorf("firstRun hook = %+v", fr) + } + if len(fr.Command) != 3 || fr.Timeout != "5m" || fr.Retries != 3 || fr.OnFailure != "abort" { + t.Errorf("firstRun hook detail = %+v", fr) + } + if m.Workspace.Hooks.IsZero() { + t.Error("workspace hooks should be non-empty (preUp banner)") + } + 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) + } +} + +// projectWith wraps a services: block in the valid workspace+project envelope. +func projectWith(services string) map[string]string { + return map[string]string{ + "workspace.yaml": sharedPGOnly, + "api/devstack.yaml": "apiVersion: devstack/v1\nkind: Project\nname: api\n" + services, + } +} + +func TestHealthcheckBadKind(t *testing.T) { + root := writeTree(t, projectWith(`services: + api: + template: t + healthcheck: { kind: gopher, port: 1 } +`)) + _, err := LoadAt(root) + if err == nil || !strings.Contains(err.Error(), "Kind") { + t.Fatalf("want a healthcheck kind error, got %v", err) + } +} + +func TestHealthcheckBadDuration(t *testing.T) { + root := writeTree(t, projectWith(`services: + api: + template: t + healthcheck: { kind: tcp, port: 1, interval: "5 seconds" } +`)) + _, err := LoadAt(root) + if err == nil || !strings.Contains(err.Error(), "duration") { + t.Fatalf("want a duration error, got %v", err) + } +} + +func TestDependsOnBadCondition(t *testing.T) { + root := writeTree(t, projectWith(`services: + api: + template: t + dependsOn: + - { service: workspace.shared.postgres, condition: someday } +`)) + _, err := LoadAt(root) + if err == nil || !strings.Contains(err.Error(), "Condition") { + t.Fatalf("want a condition oneof error, got %v", err) + } +} + +func TestHookBadRunTransport(t *testing.T) { + root := writeTree(t, projectWith(`services: + api: { template: t } +hooks: + postUp: + - { name: x, run: telepathy, command: ["true"] } +`)) + _, err := LoadAt(root) + if err == nil || !strings.Contains(err.Error(), "Run") { + t.Fatalf("want a hook run oneof error, got %v", err) + } +} + +func TestHookMissingCommand(t *testing.T) { + root := writeTree(t, projectWith(`services: + api: { template: t } +hooks: + postUp: + - { name: x, run: host } +`)) + _, err := LoadAt(root) + if err == nil || !strings.Contains(err.Error(), "Command") { + t.Fatalf("want a missing-command error, got %v", err) + } } const sharedPGOnly = `apiVersion: devstack/v1 diff --git a/internal/config/model.go b/internal/config/model.go index a2b21f1..014dc3a 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -30,6 +30,7 @@ type Workspace struct { Groups map[string]Group `yaml:"groups"` // spec 12 — workspace-level service slices Secrets Secrets `yaml:"secrets"` Network Network `yaml:"network"` + Hooks Hooks `yaml:"hooks"` // spec 11 — workspace-scope lifecycle hooks Shared map[string]SharedSvc `yaml:"shared" validate:"dive"` Projects []ProjectRef `yaml:"projects" validate:"dive"` } @@ -101,16 +102,54 @@ type Project struct { Kind string `yaml:"kind" validate:"required,eq=Project"` Name string `yaml:"name" validate:"required,dsname"` Services map[string]Service `yaml:"services" validate:"required,dive"` + Hooks Hooks `yaml:"hooks"` // spec 11 — project-scope lifecycle hooks } // Service is one container in a project stack. type Service struct { - Template string `yaml:"template" validate:"required"` - Params map[string]any `yaml:"params"` - Uses []string `yaml:"uses"` // consume SHARED services: workspace.shared. - Env Env `yaml:"env"` - Ports map[string]int `yaml:"ports"` - Profiles []string `yaml:"profiles"` // spec 12 — Compose profile membership tags + Template string `yaml:"template" validate:"required"` + Params map[string]any `yaml:"params"` + Uses []string `yaml:"uses"` // consume SHARED services: workspace.shared. + Env Env `yaml:"env"` + Ports map[string]int `yaml:"ports"` + Profiles []string `yaml:"profiles"` // spec 12 — Compose profile membership tags + Healthcheck *Healthcheck `yaml:"healthcheck"` // spec 10 — readiness probe (nil = none) + DependsOn []DependsOn `yaml:"dependsOn" validate:"dive"` // spec 10 — ordering edges +} + +// Healthcheck declares a service's readiness probe (spec 10). It compiles to +// BOTH a Compose-native healthcheck: block and a tool-side prober; this struct +// is the declarative source — `internal/health` (C3b) normalizes durations to +// time.Duration and owns the per-kind semantics. A nil *Healthcheck means the +// service declares no check (it may still inherit one from its template). +// +// Duration fields (interval/timeout/startPeriod) are Compose-style strings +// (e.g. "5s", "1m30s") validated here as parseable Go durations; they stay +// strings because the compose lowering emits strings verbatim. +type Healthcheck struct { + Kind string `yaml:"kind" validate:"required,oneof=tcp http https exec pg_isready redis"` + Port int `yaml:"port"` + Path string `yaml:"path"` // http/https + ExpectStatus string `yaml:"expectStatus"` // http/https; "200" or a "200-399" range + Host string `yaml:"host"` // http/https Host header + Command []string `yaml:"command"` // exec kind: argv (exit 0 = healthy) + User string `yaml:"user"` // pg_isready + DB string `yaml:"db"` // pg_isready + Auth string `yaml:"auth"` // redis (may be a secret:// ref) + Interval string `yaml:"interval" validate:"omitempty,duration"` + Timeout string `yaml:"timeout" validate:"omitempty,duration"` + Retries int `yaml:"retries"` + StartPeriod string `yaml:"startPeriod" validate:"omitempty,duration"` +} + +// DependsOn is one readiness-ordering edge (spec 10). Service targets an +// intra-project service name or a shared service ("workspace.shared."). +// Condition is "healthy" (default) or "started"; a "healthy" edge requires the +// target to declare a healthcheck — that semantic check lives in `internal/health` +// (generate-time, C3b/X2), not in these structural rules. +type DependsOn struct { + Service string `yaml:"service" validate:"required"` + Condition string `yaml:"condition" validate:"omitempty,oneof=healthy started"` } // Env declares the container environment. `raw`/`prefixed` are literal (with @@ -127,6 +166,46 @@ type Import struct { Vars []string `yaml:"vars"` } +// Hooks groups the lifecycle-hook lists by saga phase (spec 11). It attaches to +// a Project (per-repo) and to the Workspace (whole-bootstrap); a hook targets a +// service via Hook.Service, not by nesting under a service. Lists REPLACE on +// overlay merge unless the YAML opts into `$merge: append` (spec 02). The +// idempotency/ordering semantics (firstRun ledger, run:exec gating) belong to +// `internal/hooks` (C4); these are the declarative shape only. +type Hooks struct { + PreUp []Hook `yaml:"preUp" validate:"dive"` + FirstRun []Hook `yaml:"firstRun" validate:"dive"` + PostUp []Hook `yaml:"postUp" validate:"dive"` + PostPull []Hook `yaml:"postPull" validate:"dive"` + PreDown []Hook `yaml:"preDown" validate:"dive"` +} + +// IsZero reports whether no hooks are declared (all phase lists empty), so +// callers can cheaply skip the hook machinery. +func (h Hooks) IsZero() bool { + return len(h.PreUp) == 0 && len(h.FirstRun) == 0 && len(h.PostUp) == 0 && + len(h.PostPull) == 0 && len(h.PreDown) == 0 +} + +// Hook is one declarative command run at a saga phase (spec 11). `run: host` +// executes via os/exec from a documented working dir; `run: exec` shells into a +// running service via `compose exec -T`. Command is an argv array (never +// shell-split by us). Timeout is a Go duration string (default applied by C4). +// `service` is required when run==exec — enforced semantically in `internal/hooks`, +// not here, because that rule is transport-specific. +type Hook struct { + Name string `yaml:"name" validate:"required,dsname"` + Run string `yaml:"run" validate:"required,oneof=host exec"` + Service string `yaml:"service"` // target for run:exec + Command []string `yaml:"command" validate:"required,min=1"` + Workdir string `yaml:"workdir"` + Env map[string]string `yaml:"env"` + Timeout string `yaml:"timeout" validate:"omitempty,duration"` + Retries int `yaml:"retries"` + OnFailure string `yaml:"onFailure" validate:"omitempty,oneof=abort warn continue"` + Once bool `yaml:"once"` +} + // Model is the assembled, validated workspace: workspace.yaml plus every // project's devstack.yaml keyed by project name. Immutable after Load. type Model struct { diff --git a/internal/config/testdata/valid/services/api/devstack.yaml b/internal/config/testdata/valid/services/api/devstack.yaml index 24f4261..4ce1a3d 100644 --- a/internal/config/testdata/valid/services/api/devstack.yaml +++ b/internal/config/testdata/valid/services/api/devstack.yaml @@ -14,3 +14,28 @@ services: import: - { from: workspace.shared.postgres, vars: [host, port, user, password, database] } ports: { http: 8080 } + healthcheck: + kind: http + port: 8080 + path: /healthz + expectStatus: "200-399" + interval: 5s + timeout: 3s + retries: 12 + startPeriod: 20s + dependsOn: + - { service: workspace.shared.postgres, condition: healthy } + - { service: workspace.shared.redis, condition: healthy } +hooks: + firstRun: + - name: migrate-and-seed + run: exec + service: api + command: ["sh", "-lc", "php artisan migrate --force && php artisan db:seed --force"] + workdir: /var/www/html + env: { APP_ENV: "${profile}" } + timeout: 5m + retries: 3 + onFailure: abort + postUp: + - { name: cache-warm, run: host, command: ["true"], onFailure: warn } diff --git a/internal/config/testdata/valid/workspace.yaml b/internal/config/testdata/valid/workspace.yaml index c2d646b..35a09ef 100644 --- a/internal/config/testdata/valid/workspace.yaml +++ b/internal/config/testdata/valid/workspace.yaml @@ -3,6 +3,9 @@ kind: Workspace name: acme aliases: [rq, uranus] profiles: { default: dev } +hooks: + preUp: + - { name: banner, run: host, command: ["true"] } shared: postgres: { template: postgres, params: { version: "16" } } redis: { template: redis, params: { version: "7" } } diff --git a/internal/config/validate.go b/internal/config/validate.go index 0625d4a..668446d 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -6,6 +6,7 @@ import ( "regexp" "sort" "strings" + "time" "github.com/go-playground/validator/v10" ) @@ -24,6 +25,12 @@ func newValidator() *validator.Validate { _ = v.RegisterValidation("dsname", func(fl validator.FieldLevel) bool { return dsNameRE.MatchString(fl.Field().String()) }) + // duration: a Compose-style Go duration string ("5s", "1m30s"). Paired with + // `omitempty` so an unset field is allowed; only non-empty values are parsed. + _ = v.RegisterValidation("duration", func(fl validator.FieldLevel) bool { + _, err := time.ParseDuration(fl.Field().String()) + return err == nil + }) return v } @@ -82,6 +89,12 @@ func describeFieldError(fe validator.FieldError) string { return fmt.Sprintf("%s = %q is not a valid name (lowercase letters/digits/-/_, starting with a letter)", field, fe.Value()) case "eq": return fmt.Sprintf("%s = %q must equal %q", field, fe.Value(), fe.Param()) + case "oneof": + 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 "min": + return fmt.Sprintf("%s must have at least %s element(s)", field, fe.Param()) default: return fmt.Sprintf("%s failed %q", field, fe.Tag()) }