Skip to content

Commit eecc740

Browse files
gustavobertoiclaude
andcommitted
feat(generate): C3c — emit compose healthcheck: + lower intra-project dependsOn (spec 10)
Translate the spec-10 readiness config into the generated compose document: - A service-declared healthcheck lowers to a Compose-native `healthcheck:` block (per-kind `test`): tcp→`nc -z`, http/https→`curl -fsS [-k] -o /dev/null URL`, exec→`CMD <argv>`, pg_isready→`pg_isready -p -U [-d]`, redis→`redis-cli -p [-a] PING`. Timing (interval/timeout/retries/start_period) is emitted only when set, keeping output deterministic. A secret:// redis auth is NEVER embedded (§7.5) — redis-cli reads $REDISCLI_AUTH from the container env. - Intra-project dependsOn lowers to compose `depends_on: { dep: { condition: service_healthy|service_started } }`. CROSS-project edges (shared services, other projects) are skipped — compose can't express them; the up saga gates them tool-side via internal/health. A missing intra-project target is a generate error. The richer generate-time "condition:healthy ⇒ target declares a healthcheck" validation with file:line:col is X2; here compose-go's consistency check is the backstop (verified: it accepts service_healthy only when the target has a healthcheck — covered by an end-to-end test). Unit tests: per-kind test lowering, timing-only-when-set, dependsOn classification (intra bare/qualified emitted; shared/other-project skipped; missing target errors), redis secret-auth omission. End-to-end: a sibling depends_on healthy edge reaches compose and compose-go accepts it. The api golden gains its lowered http healthcheck (regenerated). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de08588 commit eecc740

4 files changed

Lines changed: 364 additions & 0 deletions

File tree

internal/generate/compose.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,26 @@ func buildProjectService(res *graphResolver, m *config.Model, project, service s
9797
if exp := exposeList(svc.Ports); len(exp) > 0 {
9898
out["expose"] = exp
9999
}
100+
101+
// spec 10 — a service-declared healthcheck overrides any template default and
102+
// is lowered to a Compose-native healthcheck: block.
103+
if svc.Healthcheck != nil {
104+
hc, err := healthcheckBlock(svc.Healthcheck)
105+
if err != nil {
106+
return nil, fmt.Errorf("project %q service %q healthcheck: %w", project, service, err)
107+
}
108+
out["healthcheck"] = hc
109+
}
110+
// spec 10 — intra-project dependsOn → compose depends_on (cross-project edges
111+
// are gated tool-side by the up saga, not expressible in compose).
112+
dep, err := dependsOnBlock(m, project, svc.DependsOn)
113+
if err != nil {
114+
return nil, err
115+
}
116+
if len(dep) > 0 {
117+
out["depends_on"] = dep
118+
}
119+
100120
// NOTE: service-level compose `profiles:` are deliberately NOT emitted in M1.
101121
// Compose disables a profiled service unless its profile is active, which would
102122
// drop it from the generated document and from a plain `up`. Profile membership

internal/generate/health.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package generate
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
7+
"github.com/open-source-cloud/devstack/internal/config"
8+
)
9+
10+
// This file lowers spec-10 readiness config into the generated compose document:
11+
// a service's `healthcheck:` block (the Compose-native, in-container probe) and
12+
// its intra-project `dependsOn` → compose `depends_on: { dep: { condition } }`.
13+
//
14+
// CROSS-project edges (shared services, or another project's service) are NOT
15+
// lowered here: compose `depends_on` cannot reference a service in a different
16+
// compose project, so those are gated tool-side by the up saga's health poll
17+
// (internal/health, spec 10 §two-enforcement-layers). The richer generate-time
18+
// validation ("condition: healthy ⇒ the target declares a healthcheck", with
19+
// file:line:col) is X2; here compose-go's own consistency check is the backstop.
20+
21+
// healthcheckBlock lowers a config.Healthcheck into a Compose-native healthcheck
22+
// map. Timing fields are emitted only when set (compose applies its own defaults
23+
// otherwise); the result is deterministic for a given input.
24+
func healthcheckBlock(hc *config.Healthcheck) (map[string]any, error) {
25+
test, err := healthcheckTest(hc)
26+
if err != nil {
27+
return nil, err
28+
}
29+
out := map[string]any{"test": test}
30+
if hc.Interval != "" {
31+
out["interval"] = hc.Interval
32+
}
33+
if hc.Timeout != "" {
34+
out["timeout"] = hc.Timeout
35+
}
36+
if hc.Retries > 0 {
37+
out["retries"] = hc.Retries
38+
}
39+
if hc.StartPeriod != "" {
40+
out["start_period"] = hc.StartPeriod
41+
}
42+
return out, nil
43+
}
44+
45+
// healthcheckTest lowers a healthcheck kind to a compose `test` directive (spec
46+
// 10 §kinds). The probe runs IN-CONTAINER (Compose owns it), so it relies on the
47+
// image carrying the relevant client (curl / pg_isready / redis-cli / nc).
48+
func healthcheckTest(hc *config.Healthcheck) ([]any, error) {
49+
switch hc.Kind {
50+
case "tcp":
51+
if hc.Port == 0 {
52+
return nil, fmt.Errorf("kind tcp requires a port")
53+
}
54+
return cmdShell(fmt.Sprintf("nc -z localhost %d", hc.Port)), nil
55+
case "http", "https":
56+
host := hc.Host
57+
if host == "" {
58+
host = "localhost"
59+
}
60+
path := hc.Path
61+
if path == "" {
62+
path = "/"
63+
}
64+
url := hc.Kind + "://" + host
65+
if hc.Port != 0 {
66+
url = fmt.Sprintf("%s://%s:%d", hc.Kind, host, hc.Port)
67+
}
68+
url += path
69+
args := []any{"CMD", "curl", "-fsS"}
70+
if hc.Kind == "https" {
71+
args = append(args, "-k") // local CA: skip-verify (spec 10)
72+
}
73+
args = append(args, "-o", "/dev/null", url)
74+
return args, nil
75+
case "exec":
76+
if len(hc.Command) == 0 {
77+
return nil, fmt.Errorf("kind exec requires a command")
78+
}
79+
out := make([]any, 0, len(hc.Command)+1)
80+
out = append(out, "CMD")
81+
for _, c := range hc.Command {
82+
out = append(out, c)
83+
}
84+
return out, nil
85+
case "pg_isready":
86+
port := hc.Port
87+
if port == 0 {
88+
port = 5432
89+
}
90+
user := hc.User
91+
if user == "" {
92+
user = "postgres"
93+
}
94+
cmd := fmt.Sprintf("pg_isready -p %d -U %s", port, user)
95+
if hc.DB != "" {
96+
cmd += " -d " + hc.DB
97+
}
98+
return cmdShell(cmd), nil
99+
case "redis":
100+
port := hc.Port
101+
if port == 0 {
102+
port = 6379
103+
}
104+
cmd := "redis-cli -p " + strconv.Itoa(port)
105+
// A secret:// auth ref must never be written to the generated file (§7.5):
106+
// redis-cli reads $REDISCLI_AUTH from the container env, so we omit -a and
107+
// rely on that. Only a plain (already-committed) literal is embedded.
108+
if hc.Auth != "" && !isSecretRef(hc.Auth) {
109+
cmd += " -a " + hc.Auth
110+
}
111+
cmd += " PING"
112+
return cmdShell(cmd), nil
113+
default:
114+
// config validation restricts kind to the oneof set; defensive only.
115+
return nil, fmt.Errorf("unknown healthcheck kind %q", hc.Kind)
116+
}
117+
}
118+
119+
func cmdShell(s string) []any { return []any{"CMD-SHELL", s} }
120+
121+
// isSecretRef reports whether a value is a secret:// reference (resolved later by
122+
// internal/secrets, never embedded in a generated file).
123+
func isSecretRef(s string) bool { return len(s) >= 9 && s[:9] == "secret://" }
124+
125+
// dependsOnBlock lowers a service's INTRA-project dependsOn edges to a compose
126+
// `depends_on` map. Cross-project edges (shared, or another project) are skipped
127+
// (the saga's tool-side poll handles them). An intra-project target that does not
128+
// exist in the project is a generate error with context.
129+
func dependsOnBlock(m *config.Model, project string, deps []config.DependsOn) (map[string]any, error) {
130+
if len(deps) == 0 {
131+
return nil, nil
132+
}
133+
proj := m.Projects[project]
134+
out := map[string]any{}
135+
for _, d := range deps {
136+
target, intra := intraProjectTarget(project, d.Service)
137+
if !intra {
138+
continue // cross-project: gated tool-side, not via compose
139+
}
140+
if _, ok := proj.Services[target]; !ok {
141+
return nil, fmt.Errorf("project %q: dependsOn target %q is not a service in this project", project, d.Service)
142+
}
143+
cond := "service_healthy"
144+
if d.Condition == "started" {
145+
cond = "service_started"
146+
}
147+
out[target] = map[string]any{"condition": cond}
148+
}
149+
if len(out) == 0 {
150+
return nil, nil
151+
}
152+
return out, nil
153+
}
154+
155+
// intraProjectTarget classifies a dependsOn target. It returns the bare service
156+
// name and true when the target is in THIS compose project: a bare service name,
157+
// or workspace.<project>.<service> with project == current. Shared refs and other
158+
// projects' services return intra=false.
159+
func intraProjectTarget(project, target string) (string, bool) {
160+
ref, ok := config.ParseRef(target)
161+
if !ok {
162+
// Not a dotted reference → a bare intra-project service name.
163+
return target, true
164+
}
165+
if ref.Kind == config.RefService && ref.Project == project {
166+
return ref.Name, true
167+
}
168+
return "", false
169+
}

internal/generate/health_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package generate
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/open-source-cloud/devstack/internal/config"
11+
"github.com/open-source-cloud/devstack/internal/template"
12+
"github.com/open-source-cloud/devstack/templates"
13+
)
14+
15+
func TestHealthcheckTestKinds(t *testing.T) {
16+
cases := []struct {
17+
name string
18+
hc config.Healthcheck
19+
want []any
20+
}{
21+
{"tcp", config.Healthcheck{Kind: "tcp", Port: 5432},
22+
[]any{"CMD-SHELL", "nc -z localhost 5432"}},
23+
{"http", config.Healthcheck{Kind: "http", Port: 8080, Path: "/healthz"},
24+
[]any{"CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8080/healthz"}},
25+
{"https-skipverify", config.Healthcheck{Kind: "https", Port: 443, Path: "/"},
26+
[]any{"CMD", "curl", "-fsS", "-k", "-o", "/dev/null", "https://localhost:443/"}},
27+
{"exec", config.Healthcheck{Kind: "exec", Command: []string{"mysqladmin", "ping"}},
28+
[]any{"CMD", "mysqladmin", "ping"}},
29+
{"pg_isready", config.Healthcheck{Kind: "pg_isready", User: "app", DB: "appdb"},
30+
[]any{"CMD-SHELL", "pg_isready -p 5432 -U app -d appdb"}},
31+
{"redis-default", config.Healthcheck{Kind: "redis"},
32+
[]any{"CMD-SHELL", "redis-cli -p 6379 PING"}},
33+
{"redis-literal-auth", config.Healthcheck{Kind: "redis", Auth: "devpass"},
34+
[]any{"CMD-SHELL", "redis-cli -p 6379 -a devpass PING"}},
35+
{"redis-secret-auth-omitted", config.Healthcheck{Kind: "redis", Auth: "secret://vault/redis#pw"},
36+
[]any{"CMD-SHELL", "redis-cli -p 6379 PING"}},
37+
}
38+
for _, c := range cases {
39+
t.Run(c.name, func(t *testing.T) {
40+
got, err := healthcheckTest(&c.hc)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
if fmt.Sprint(got) != fmt.Sprint(c.want) {
45+
t.Errorf("test = %v, want %v", got, c.want)
46+
}
47+
})
48+
}
49+
}
50+
51+
func TestHealthcheckTestErrors(t *testing.T) {
52+
for _, hc := range []config.Healthcheck{
53+
{Kind: "tcp"}, // no port
54+
{Kind: "exec"}, // no command
55+
} {
56+
if _, err := healthcheckTest(&hc); err == nil {
57+
t.Errorf("kind %q with missing params should error", hc.Kind)
58+
}
59+
}
60+
}
61+
62+
func TestHealthcheckBlockTimingOnlyWhenSet(t *testing.T) {
63+
// Only the test is present when timing is unset.
64+
bare, _ := healthcheckBlock(&config.Healthcheck{Kind: "tcp", Port: 1})
65+
if len(bare) != 1 {
66+
t.Errorf("bare block = %v, want only test", bare)
67+
}
68+
full, _ := healthcheckBlock(&config.Healthcheck{
69+
Kind: "tcp", Port: 1, Interval: "5s", Timeout: "3s", Retries: 7, StartPeriod: "20s",
70+
})
71+
for _, k := range []string{"test", "interval", "timeout", "retries", "start_period"} {
72+
if _, ok := full[k]; !ok {
73+
t.Errorf("full block missing %q: %v", k, full)
74+
}
75+
}
76+
}
77+
78+
func TestDependsOnBlockClassification(t *testing.T) {
79+
m := &config.Model{Projects: map[string]config.Project{
80+
"api": {Services: map[string]config.Service{
81+
"web": {Template: "t"},
82+
"cache": {Template: "t"},
83+
}},
84+
}}
85+
deps := []config.DependsOn{
86+
{Service: "cache", Condition: "healthy"}, // intra (bare)
87+
{Service: "workspace.api.web", Condition: "started"}, // intra (qualified)
88+
{Service: "workspace.shared.postgres", Condition: "healthy"}, // shared → skip
89+
{Service: "workspace.other.svc", Condition: "healthy"}, // other project → skip
90+
}
91+
got, err := dependsOnBlock(m, "api", deps)
92+
if err != nil {
93+
t.Fatal(err)
94+
}
95+
if len(got) != 2 {
96+
t.Fatalf("depends_on = %v, want 2 intra-project edges", got)
97+
}
98+
if got["cache"].(map[string]any)["condition"] != "service_healthy" {
99+
t.Errorf("cache condition = %v, want service_healthy", got["cache"])
100+
}
101+
if got["web"].(map[string]any)["condition"] != "service_started" {
102+
t.Errorf("web condition = %v, want service_started", got["web"])
103+
}
104+
}
105+
106+
func TestDependsOnBlockMissingTarget(t *testing.T) {
107+
m := &config.Model{Projects: map[string]config.Project{
108+
"api": {Services: map[string]config.Service{"web": {Template: "t"}}},
109+
}}
110+
_, err := dependsOnBlock(m, "api", []config.DependsOn{{Service: "ghost"}})
111+
if err == nil || !strings.Contains(err.Error(), "ghost") {
112+
t.Fatalf("want a missing-target error naming ghost, got %v", err)
113+
}
114+
}
115+
116+
// TestIntraProjectDependsOn_EndToEnd generates a real project where one service
117+
// depends on a sibling (condition healthy) and the sibling declares a
118+
// healthcheck, asserting the lowering reaches compose AND compose-go accepts the
119+
// service_healthy edge.
120+
func TestIntraProjectDependsOn_EndToEnd(t *testing.T) {
121+
root := t.TempDir()
122+
write := func(rel, body string) {
123+
p := filepath.Join(root, rel)
124+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
125+
t.Fatal(err)
126+
}
127+
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
128+
t.Fatal(err)
129+
}
130+
}
131+
write("workspace.yaml", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n")
132+
write("app/devstack.yaml", `apiVersion: devstack/v1
133+
kind: Project
134+
name: app
135+
services:
136+
cache:
137+
template: node.vite
138+
healthcheck: { kind: tcp, port: 6379, interval: 2s }
139+
web:
140+
template: node.vite
141+
dependsOn:
142+
- { service: cache, condition: healthy }
143+
`)
144+
m, err := config.LoadAt(root)
145+
if err != nil {
146+
t.Fatalf("load: %v", err)
147+
}
148+
g, err := New(m, template.NewFSSource(templates.FS), WithEnv(map[string]string{}))
149+
if err != nil {
150+
t.Fatalf("New: %v", err)
151+
}
152+
st, err := g.GenerateProject("app")
153+
if err != nil {
154+
t.Fatalf("GenerateProject: %v", err)
155+
}
156+
compose := string(st.Compose)
157+
if !strings.Contains(compose, "depends_on:") || !strings.Contains(compose, "condition: service_healthy") {
158+
t.Errorf("compose missing lowered depends_on:\n%s", compose)
159+
}
160+
if !strings.Contains(compose, "cache") {
161+
t.Errorf("compose should reference the cache dependency:\n%s", compose)
162+
}
163+
}

internal/generate/testdata/golden/devstack-api.docker-compose.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ services:
2323
XDEBUG_MODE: "off"
2424
expose:
2525
- "8080"
26+
healthcheck:
27+
test:
28+
- CMD
29+
- curl
30+
- -fsS
31+
- -o
32+
- /dev/null
33+
- http://localhost:8080/healthz
34+
timeout: 3s
35+
interval: 5s
36+
retries: 12
37+
start_period: 20s
2638
labels:
2739
com.devstack.managed: "true"
2840
com.devstack.project: api

0 commit comments

Comments
 (0)