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
20 changes: 20 additions & 0 deletions internal/generate/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
169 changes: 169 additions & 0 deletions internal/generate/health.go
Original file line number Diff line number Diff line change
@@ -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.<project>.<service> 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
}
163 changes: 163 additions & 0 deletions internal/generate/health_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
12 changes: 12 additions & 0 deletions internal/generate/testdata/golden/devstack-api.docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading