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
107 changes: 107 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 85 additions & 6 deletions internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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.<name>
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.<name>
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.<name>").
// 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
Expand All @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions internal/config/testdata/valid/services/api/devstack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
3 changes: 3 additions & 0 deletions internal/config/testdata/valid/workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }
Expand Down
13 changes: 13 additions & 0 deletions internal/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"regexp"
"sort"
"strings"
"time"

"github.com/go-playground/validator/v10"
)
Expand All @@ -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
}

Expand Down Expand Up @@ -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())
}
Expand Down
Loading