diff --git a/internal/generate/compose.go b/internal/generate/compose.go index bad302e..267208c 100644 --- a/internal/generate/compose.go +++ b/internal/generate/compose.go @@ -97,6 +97,26 @@ func buildProjectService(res *graphResolver, m *config.Model, project, service s if exp := exposeList(svc.Ports); len(exp) > 0 { out["expose"] = exp } + + // spec 10 — a service-declared healthcheck overrides any template default and + // is lowered to a Compose-native healthcheck: block. + if svc.Healthcheck != nil { + hc, err := healthcheckBlock(svc.Healthcheck) + if err != nil { + return nil, fmt.Errorf("project %q service %q healthcheck: %w", project, service, err) + } + out["healthcheck"] = hc + } + // spec 10 — intra-project dependsOn → compose depends_on (cross-project edges + // are gated tool-side by the up saga, not expressible in compose). + dep, err := dependsOnBlock(m, project, svc.DependsOn) + if err != nil { + return nil, err + } + if len(dep) > 0 { + out["depends_on"] = dep + } + // NOTE: service-level compose `profiles:` are deliberately NOT emitted in M1. // Compose disables a profiled service unless its profile is active, which would // drop it from the generated document and from a plain `up`. Profile membership diff --git a/internal/generate/health.go b/internal/generate/health.go new file mode 100644 index 0000000..facb99f --- /dev/null +++ b/internal/generate/health.go @@ -0,0 +1,169 @@ +package generate + +import ( + "fmt" + "strconv" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// This file lowers spec-10 readiness config into the generated compose document: +// a service's `healthcheck:` block (the Compose-native, in-container probe) and +// its intra-project `dependsOn` → compose `depends_on: { dep: { condition } }`. +// +// CROSS-project edges (shared services, or another project's service) are NOT +// lowered here: compose `depends_on` cannot reference a service in a different +// compose project, so those are gated tool-side by the up saga's health poll +// (internal/health, spec 10 §two-enforcement-layers). The richer generate-time +// validation ("condition: healthy ⇒ the target declares a healthcheck", with +// file:line:col) is X2; here compose-go's own consistency check is the backstop. + +// healthcheckBlock lowers a config.Healthcheck into a Compose-native healthcheck +// map. Timing fields are emitted only when set (compose applies its own defaults +// otherwise); the result is deterministic for a given input. +func healthcheckBlock(hc *config.Healthcheck) (map[string]any, error) { + test, err := healthcheckTest(hc) + if err != nil { + return nil, err + } + out := map[string]any{"test": test} + if hc.Interval != "" { + out["interval"] = hc.Interval + } + if hc.Timeout != "" { + out["timeout"] = hc.Timeout + } + if hc.Retries > 0 { + out["retries"] = hc.Retries + } + if hc.StartPeriod != "" { + out["start_period"] = hc.StartPeriod + } + return out, nil +} + +// healthcheckTest lowers a healthcheck kind to a compose `test` directive (spec +// 10 §kinds). The probe runs IN-CONTAINER (Compose owns it), so it relies on the +// image carrying the relevant client (curl / pg_isready / redis-cli / nc). +func healthcheckTest(hc *config.Healthcheck) ([]any, error) { + switch hc.Kind { + case "tcp": + if hc.Port == 0 { + return nil, fmt.Errorf("kind tcp requires a port") + } + return cmdShell(fmt.Sprintf("nc -z localhost %d", hc.Port)), nil + case "http", "https": + host := hc.Host + if host == "" { + host = "localhost" + } + path := hc.Path + if path == "" { + path = "/" + } + url := hc.Kind + "://" + host + if hc.Port != 0 { + url = fmt.Sprintf("%s://%s:%d", hc.Kind, host, hc.Port) + } + url += path + args := []any{"CMD", "curl", "-fsS"} + if hc.Kind == "https" { + args = append(args, "-k") // local CA: skip-verify (spec 10) + } + args = append(args, "-o", "/dev/null", url) + return args, nil + case "exec": + if len(hc.Command) == 0 { + return nil, fmt.Errorf("kind exec requires a command") + } + out := make([]any, 0, len(hc.Command)+1) + out = append(out, "CMD") + for _, c := range hc.Command { + out = append(out, c) + } + return out, nil + case "pg_isready": + port := hc.Port + if port == 0 { + port = 5432 + } + user := hc.User + if user == "" { + user = "postgres" + } + cmd := fmt.Sprintf("pg_isready -p %d -U %s", port, user) + if hc.DB != "" { + cmd += " -d " + hc.DB + } + return cmdShell(cmd), nil + case "redis": + port := hc.Port + if port == 0 { + port = 6379 + } + cmd := "redis-cli -p " + strconv.Itoa(port) + // A secret:// auth ref must never be written to the generated file (§7.5): + // redis-cli reads $REDISCLI_AUTH from the container env, so we omit -a and + // rely on that. Only a plain (already-committed) literal is embedded. + if hc.Auth != "" && !isSecretRef(hc.Auth) { + cmd += " -a " + hc.Auth + } + cmd += " PING" + return cmdShell(cmd), nil + default: + // config validation restricts kind to the oneof set; defensive only. + return nil, fmt.Errorf("unknown healthcheck kind %q", hc.Kind) + } +} + +func cmdShell(s string) []any { return []any{"CMD-SHELL", s} } + +// isSecretRef reports whether a value is a secret:// reference (resolved later by +// internal/secrets, never embedded in a generated file). +func isSecretRef(s string) bool { return len(s) >= 9 && s[:9] == "secret://" } + +// dependsOnBlock lowers a service's INTRA-project dependsOn edges to a compose +// `depends_on` map. Cross-project edges (shared, or another project) are skipped +// (the saga's tool-side poll handles them). An intra-project target that does not +// exist in the project is a generate error with context. +func dependsOnBlock(m *config.Model, project string, deps []config.DependsOn) (map[string]any, error) { + if len(deps) == 0 { + return nil, nil + } + proj := m.Projects[project] + out := map[string]any{} + for _, d := range deps { + target, intra := intraProjectTarget(project, d.Service) + if !intra { + continue // cross-project: gated tool-side, not via compose + } + if _, ok := proj.Services[target]; !ok { + return nil, fmt.Errorf("project %q: dependsOn target %q is not a service in this project", project, d.Service) + } + cond := "service_healthy" + if d.Condition == "started" { + cond = "service_started" + } + out[target] = map[string]any{"condition": cond} + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + +// intraProjectTarget classifies a dependsOn target. It returns the bare service +// name and true when the target is in THIS compose project: a bare service name, +// or workspace.. with project == current. Shared refs and other +// projects' services return intra=false. +func intraProjectTarget(project, target string) (string, bool) { + ref, ok := config.ParseRef(target) + if !ok { + // Not a dotted reference → a bare intra-project service name. + return target, true + } + if ref.Kind == config.RefService && ref.Project == project { + return ref.Name, true + } + return "", false +} diff --git a/internal/generate/health_test.go b/internal/generate/health_test.go new file mode 100644 index 0000000..5774560 --- /dev/null +++ b/internal/generate/health_test.go @@ -0,0 +1,163 @@ +package generate + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/templates" +) + +func TestHealthcheckTestKinds(t *testing.T) { + cases := []struct { + name string + hc config.Healthcheck + want []any + }{ + {"tcp", config.Healthcheck{Kind: "tcp", Port: 5432}, + []any{"CMD-SHELL", "nc -z localhost 5432"}}, + {"http", config.Healthcheck{Kind: "http", Port: 8080, Path: "/healthz"}, + []any{"CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8080/healthz"}}, + {"https-skipverify", config.Healthcheck{Kind: "https", Port: 443, Path: "/"}, + []any{"CMD", "curl", "-fsS", "-k", "-o", "/dev/null", "https://localhost:443/"}}, + {"exec", config.Healthcheck{Kind: "exec", Command: []string{"mysqladmin", "ping"}}, + []any{"CMD", "mysqladmin", "ping"}}, + {"pg_isready", config.Healthcheck{Kind: "pg_isready", User: "app", DB: "appdb"}, + []any{"CMD-SHELL", "pg_isready -p 5432 -U app -d appdb"}}, + {"redis-default", config.Healthcheck{Kind: "redis"}, + []any{"CMD-SHELL", "redis-cli -p 6379 PING"}}, + {"redis-literal-auth", config.Healthcheck{Kind: "redis", Auth: "devpass"}, + []any{"CMD-SHELL", "redis-cli -p 6379 -a devpass PING"}}, + {"redis-secret-auth-omitted", config.Healthcheck{Kind: "redis", Auth: "secret://vault/redis#pw"}, + []any{"CMD-SHELL", "redis-cli -p 6379 PING"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := healthcheckTest(&c.hc) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(got) != fmt.Sprint(c.want) { + t.Errorf("test = %v, want %v", got, c.want) + } + }) + } +} + +func TestHealthcheckTestErrors(t *testing.T) { + for _, hc := range []config.Healthcheck{ + {Kind: "tcp"}, // no port + {Kind: "exec"}, // no command + } { + if _, err := healthcheckTest(&hc); err == nil { + t.Errorf("kind %q with missing params should error", hc.Kind) + } + } +} + +func TestHealthcheckBlockTimingOnlyWhenSet(t *testing.T) { + // Only the test is present when timing is unset. + bare, _ := healthcheckBlock(&config.Healthcheck{Kind: "tcp", Port: 1}) + if len(bare) != 1 { + t.Errorf("bare block = %v, want only test", bare) + } + full, _ := healthcheckBlock(&config.Healthcheck{ + Kind: "tcp", Port: 1, Interval: "5s", Timeout: "3s", Retries: 7, StartPeriod: "20s", + }) + for _, k := range []string{"test", "interval", "timeout", "retries", "start_period"} { + if _, ok := full[k]; !ok { + t.Errorf("full block missing %q: %v", k, full) + } + } +} + +func TestDependsOnBlockClassification(t *testing.T) { + m := &config.Model{Projects: map[string]config.Project{ + "api": {Services: map[string]config.Service{ + "web": {Template: "t"}, + "cache": {Template: "t"}, + }}, + }} + deps := []config.DependsOn{ + {Service: "cache", Condition: "healthy"}, // intra (bare) + {Service: "workspace.api.web", Condition: "started"}, // intra (qualified) + {Service: "workspace.shared.postgres", Condition: "healthy"}, // shared → skip + {Service: "workspace.other.svc", Condition: "healthy"}, // other project → skip + } + got, err := dependsOnBlock(m, "api", deps) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("depends_on = %v, want 2 intra-project edges", got) + } + if got["cache"].(map[string]any)["condition"] != "service_healthy" { + t.Errorf("cache condition = %v, want service_healthy", got["cache"]) + } + if got["web"].(map[string]any)["condition"] != "service_started" { + t.Errorf("web condition = %v, want service_started", got["web"]) + } +} + +func TestDependsOnBlockMissingTarget(t *testing.T) { + m := &config.Model{Projects: map[string]config.Project{ + "api": {Services: map[string]config.Service{"web": {Template: "t"}}}, + }} + _, err := dependsOnBlock(m, "api", []config.DependsOn{{Service: "ghost"}}) + if err == nil || !strings.Contains(err.Error(), "ghost") { + t.Fatalf("want a missing-target error naming ghost, got %v", err) + } +} + +// TestIntraProjectDependsOn_EndToEnd generates a real project where one service +// depends on a sibling (condition healthy) and the sibling declares a +// healthcheck, asserting the lowering reaches compose AND compose-go accepts the +// service_healthy edge. +func TestIntraProjectDependsOn_EndToEnd(t *testing.T) { + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("workspace.yaml", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n") + write("app/devstack.yaml", `apiVersion: devstack/v1 +kind: Project +name: app +services: + cache: + template: node.vite + healthcheck: { kind: tcp, port: 6379, interval: 2s } + web: + template: node.vite + dependsOn: + - { service: cache, condition: healthy } +`) + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load: %v", err) + } + g, err := New(m, template.NewFSSource(templates.FS), WithEnv(map[string]string{})) + if err != nil { + t.Fatalf("New: %v", err) + } + st, err := g.GenerateProject("app") + if err != nil { + t.Fatalf("GenerateProject: %v", err) + } + compose := string(st.Compose) + if !strings.Contains(compose, "depends_on:") || !strings.Contains(compose, "condition: service_healthy") { + t.Errorf("compose missing lowered depends_on:\n%s", compose) + } + if !strings.Contains(compose, "cache") { + t.Errorf("compose should reference the cache dependency:\n%s", compose) + } +} diff --git a/internal/generate/testdata/golden/devstack-api.docker-compose.yaml b/internal/generate/testdata/golden/devstack-api.docker-compose.yaml index 44c8283..db375d3 100644 --- a/internal/generate/testdata/golden/devstack-api.docker-compose.yaml +++ b/internal/generate/testdata/golden/devstack-api.docker-compose.yaml @@ -23,6 +23,18 @@ services: XDEBUG_MODE: "off" expose: - "8080" + healthcheck: + test: + - CMD + - curl + - -fsS + - -o + - /dev/null + - http://localhost:8080/healthz + timeout: 3s + interval: 5s + retries: 12 + start_period: 20s labels: com.devstack.managed: "true" com.devstack.project: api