From 7733f04f2b76a5bdd161c74a25411a8776f61517 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 13:17:35 -0300 Subject: [PATCH] =?UTF-8?q?feat(health):=20X2=20=E2=80=94=20workspace=20de?= =?UTF-8?q?pendency=20DAG=20(cycles,=20waves,=20healthy-needs-healthcheck)?= =?UTF-8?q?=20(spec=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full dependsOn graph the saga orders startup by: - BuildGraph(model): one workspace-wide graph — project services + shared services are nodes; each service's dependsOn becomes an edge (target resolved via the canonical ref grammar: bare→intra-project, workspace.shared.*, workspace..). An edge to a non-existent target is a hard error. - Cycle(): returns the full human-readable cycle path (a → b → a) or nil, deterministically (sorted DFS). - Waves(): stable topological waves — wave 0 is the dependency-free nodes (shared services land first), each later wave depends only on earlier ones; reproducible for the up checklist/--json. Errors with the path on a cycle. - RequireHealthchecks(pred): enforces spec-10's rule that every condition:healthy edge's target declares a healthcheck (the predicate is supplied by the caller: project services from config, shared from their template) — the generate-time guard that turns "healthy edge to a check-less service" into an error. Pure logic, no daemon. Unit-tested: node/edge build, topo waves order (shared→cache→web), cycle-with-path + Waves error, unknown-target error, healthy-needs-healthcheck (fails on missing, allows started edges). Unblocks X4 (profile-aware pruning) and X5 (saga consumes Waves). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/health/graph.go | 263 ++++++++++++++++++++++++++++++++++ internal/health/graph_test.go | 119 +++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 internal/health/graph.go create mode 100644 internal/health/graph_test.go diff --git a/internal/health/graph.go b/internal/health/graph.go new file mode 100644 index 0000000..0c9224b --- /dev/null +++ b/internal/health/graph.go @@ -0,0 +1,263 @@ +package health + +import ( + "fmt" + "sort" + "strings" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// This file is the full workspace dependency DAG (X2, spec 10): it builds one +// graph from every service's dependsOn (shared services are nodes too), detects +// cycles with the full path, topologically sorts into ordered waves (stable, so +// the up checklist + --json are reproducible), and enforces the generate-time +// rule that a `condition: healthy` edge's target must declare a healthcheck. +// Pure logic — no daemon — so it is fully unit-testable; the saga consumes Waves +// to start shared services first, gate them, then start project waves in order. + +// Node identifies a service in the dependency graph. Shared services have +// Shared=true and an empty Project. +type Node struct { + Project string + Service string + Shared bool +} + +// ID is the stable graph key. Human/log form via String. +func (n Node) ID() string { + if n.Shared { + return "shared/" + n.Service + } + return n.Project + "/" + n.Service +} + +// String is the human label used in cycle paths + errors. +func (n Node) String() string { + if n.Shared { + return "workspace.shared." + n.Service + } + return n.Project + "." + n.Service +} + +// Edge is one dependsOn edge: From depends on To, gated by Condition. +type Edge struct { + To Node + Condition string // healthy | started +} + +// Graph is the workspace-wide dependsOn DAG. +type Graph struct { + nodes map[string]Node // by ID + edges map[string][]Edge // fromID -> dependency edges +} + +// BuildGraph assembles the DAG from every project service's dependsOn. Shared +// services declared in the workspace are nodes. An edge whose target does not +// exist is a hard error (positioned by name). +func BuildGraph(m *config.Model) (*Graph, error) { + g := &Graph{nodes: map[string]Node{}, edges: map[string][]Edge{}} + + // All project-service nodes. + for _, project := range sortedKeys(m.Projects) { + for _, sname := range sortedKeys(m.Projects[project].Services) { + n := Node{Project: project, Service: sname} + g.nodes[n.ID()] = n + } + } + // Shared-service nodes. + for _, name := range sortedKeys(m.Workspace.Shared) { + n := Node{Service: name, Shared: true} + g.nodes[n.ID()] = n + } + + // dependsOn edges. + for _, project := range sortedKeys(m.Projects) { + p := m.Projects[project] + for _, sname := range sortedKeys(p.Services) { + from := Node{Project: project, Service: sname} + for _, d := range p.Services[sname].DependsOn { + to, err := resolveTarget(m, project, d.Service) + if err != nil { + return nil, fmt.Errorf("%s dependsOn %q: %w", from, d.Service, err) + } + cond := d.Condition + if cond == "" { + cond = "healthy" + } + g.edges[from.ID()] = append(g.edges[from.ID()], Edge{To: to, Condition: cond}) + } + } + } + return g, nil +} + +// resolveTarget maps a dependsOn target string to an existing graph node. +func resolveTarget(m *config.Model, project, target string) (Node, error) { + ref, ok := config.ParseRef(target) + if !ok { + // Bare name → intra-project service. + if _, exists := m.Projects[project].Services[target]; !exists { + return Node{}, fmt.Errorf("no service %q in project %q", target, project) + } + return Node{Project: project, Service: target}, nil + } + switch ref.Kind { + case config.RefShared: + if _, exists := m.Workspace.Shared[ref.Name]; !exists { + return Node{}, fmt.Errorf("shared service %q does not exist", ref.Name) + } + return Node{Service: ref.Name, Shared: true}, nil + case config.RefService: + tp, exists := m.Projects[ref.Project] + if !exists { + return Node{}, fmt.Errorf("project %q does not exist", ref.Project) + } + if _, exists := tp.Services[ref.Name]; !exists { + return Node{}, fmt.Errorf("service %q does not exist in project %q", ref.Name, ref.Project) + } + return Node{Project: ref.Project, Service: ref.Name}, nil + default: + return Node{}, fmt.Errorf("invalid reference") + } +} + +// Cycle returns the node-label path of a dependency cycle (a → b → a), or nil +// when the graph is acyclic. Deterministic (explores nodes in sorted order). +func (g *Graph) Cycle() []string { + const ( + white = 0 + gray = 1 + black = 2 + ) + color := map[string]int{} + var stack []string + var found []string + + var dfs func(id string) bool + dfs = func(id string) bool { + color[id] = gray + stack = append(stack, id) + for _, e := range g.edges[id] { + w := e.To.ID() + switch color[w] { + case gray: + // Back-edge: slice the cycle from the stack. + for i := len(stack) - 1; i >= 0; i-- { + if stack[i] == w { + cyc := append([]string{}, stack[i:]...) + cyc = append(cyc, w) + found = labelsOf(g, cyc) + return true + } + } + case white: + if dfs(w) { + return true + } + } + } + stack = stack[:len(stack)-1] + color[id] = black + return false + } + + for _, id := range sortedKeys(g.nodes) { + if color[id] == white { + if dfs(id) { + return found + } + } + } + return nil +} + +// Waves returns the topological waves (stable sort on ID within each wave): wave +// 0 has the nodes that depend on nothing, and each later wave depends only on +// earlier ones. Returns an error if the graph has a cycle (with the path). +func (g *Graph) Waves() ([][]Node, error) { + if cyc := g.Cycle(); cyc != nil { + return nil, fmt.Errorf("dependsOn cycle: %s", strings.Join(cyc, " → ")) + } + remaining := map[string]int{} // unplaced dependency count per node + dependents := map[string][]string{} // toID -> [fromID...] + for fromID := range g.edges { + seen := map[string]bool{} + for _, e := range g.edges[fromID] { + toID := e.To.ID() + if seen[toID] { + continue + } + seen[toID] = true + remaining[fromID]++ + dependents[toID] = append(dependents[toID], fromID) + } + } + + placed := map[string]bool{} + var waves [][]Node + for len(placed) < len(g.nodes) { + var waveIDs []string + for _, id := range sortedKeys(g.nodes) { + if !placed[id] && remaining[id] == 0 { + waveIDs = append(waveIDs, id) + } + } + if len(waveIDs) == 0 { + return nil, fmt.Errorf("dependsOn cycle (no schedulable wave)") // defensive; Cycle() should have caught it + } + wave := make([]Node, 0, len(waveIDs)) + for _, id := range waveIDs { + placed[id] = true + wave = append(wave, g.nodes[id]) + for _, dep := range dependents[id] { + remaining[dep]-- + } + } + waves = append(waves, wave) + } + return waves, nil +} + +// RequireHealthchecks enforces spec-10's rule: every `condition: healthy` edge's +// target must declare a healthcheck. hasHealthcheck reports whether a node's +// service declares one (project services from config; shared from their template +// — the caller supplies the predicate). Returns the first violation. +func (g *Graph) RequireHealthchecks(hasHealthcheck func(Node) bool) error { + for _, fromID := range sortedKeys(g.edges) { + from := g.nodes[fromID] + for _, e := range g.edges[fromID] { + if e.Condition == "healthy" && !hasHealthcheck(e.To) { + return fmt.Errorf("%s depends on %s with condition: healthy, but %s declares no healthcheck", + from, e.To, e.To) + } + } + } + return nil +} + +// Nodes returns all graph nodes (sorted by ID) — for callers that need the set. +func (g *Graph) Nodes() []Node { + out := make([]Node, 0, len(g.nodes)) + for _, id := range sortedKeys(g.nodes) { + out = append(out, g.nodes[id]) + } + return out +} + +func labelsOf(g *Graph, ids []string) []string { + out := make([]string, len(ids)) + for i, id := range ids { + out[i] = g.nodes[id].String() + } + return out +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/health/graph_test.go b/internal/health/graph_test.go new file mode 100644 index 0000000..5021ec3 --- /dev/null +++ b/internal/health/graph_test.go @@ -0,0 +1,119 @@ +package health + +import ( + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" +) + +func depModel() *config.Model { + return &config.Model{ + Workspace: config.Workspace{Shared: map[string]config.SharedSvc{ + "postgres": {Template: "postgres"}, + "redis": {Template: "redis"}, + }}, + Projects: map[string]config.Project{"app": {Services: map[string]config.Service{ + "web": {DependsOn: []config.DependsOn{ + {Service: "workspace.shared.postgres", Condition: "healthy"}, + {Service: "cache", Condition: "started"}, + }}, + "cache": {DependsOn: []config.DependsOn{ + {Service: "workspace.shared.postgres", Condition: "healthy"}, + }}, + }}}, + } +} + +func TestBuildGraphNodesAndEdges(t *testing.T) { + g, err := BuildGraph(depModel()) + if err != nil { + t.Fatal(err) + } + if len(g.Nodes()) != 4 { // app/web, app/cache, shared/postgres, shared/redis + t.Errorf("nodes = %d, want 4", len(g.Nodes())) + } + if cyc := g.Cycle(); cyc != nil { + t.Errorf("acyclic graph reported a cycle: %v", cyc) + } +} + +func TestWavesTopoOrder(t *testing.T) { + g, _ := BuildGraph(depModel()) + waves, err := g.Waves() + if err != nil { + t.Fatal(err) + } + if len(waves) != 3 { + t.Fatalf("waves = %d, want 3", len(waves)) + } + ids := func(w []Node) []string { + var out []string + for _, n := range w { + out = append(out, n.ID()) + } + return out + } + // wave0: the two shared services (nothing depends backward onto them). + if got := strings.Join(ids(waves[0]), ","); got != "shared/postgres,shared/redis" { + t.Errorf("wave0 = %q, want shared/postgres,shared/redis", got) + } + // wave1: cache (depends only on postgres). wave2: web (depends on cache). + if got := strings.Join(ids(waves[1]), ","); got != "app/cache" { + t.Errorf("wave1 = %q, want app/cache", got) + } + if got := strings.Join(ids(waves[2]), ","); got != "app/web" { + t.Errorf("wave2 = %q, want app/web", got) + } +} + +func TestCycleDetectedWithPath(t *testing.T) { + m := &config.Model{Projects: map[string]config.Project{"app": {Services: map[string]config.Service{ + "a": {DependsOn: []config.DependsOn{{Service: "b", Condition: "started"}}}, + "b": {DependsOn: []config.DependsOn{{Service: "a", Condition: "started"}}}, + }}}} + g, err := BuildGraph(m) + if err != nil { + t.Fatal(err) + } + cyc := g.Cycle() + if cyc == nil { + t.Fatal("expected a cycle") + } + joined := strings.Join(cyc, " → ") + if !strings.Contains(joined, "app.a") || !strings.Contains(joined, "app.b") { + t.Errorf("cycle path = %q, want it to name app.a and app.b", joined) + } + if _, err := g.Waves(); err == nil || !strings.Contains(err.Error(), "cycle") { + t.Errorf("Waves on a cyclic graph should error with the path, got %v", err) + } +} + +func TestBuildGraphUnknownTarget(t *testing.T) { + m := &config.Model{Projects: map[string]config.Project{"app": {Services: map[string]config.Service{ + "web": {DependsOn: []config.DependsOn{{Service: "ghost"}}}, + }}}} + if _, err := BuildGraph(m); err == nil || !strings.Contains(err.Error(), "ghost") { + t.Fatalf("want an unknown-target error naming ghost, got %v", err) + } +} + +func TestRequireHealthchecks(t *testing.T) { + g, _ := BuildGraph(depModel()) + + // postgres has no healthcheck → the web→postgres (healthy) edge fails. + err := g.RequireHealthchecks(func(Node) bool { return false }) + if err == nil || !strings.Contains(err.Error(), "healthcheck") { + t.Fatalf("want a missing-healthcheck error, got %v", err) + } + // All targets have healthchecks → ok. + if err := g.RequireHealthchecks(func(Node) bool { return true }); err != nil { + t.Errorf("all-healthchecks should pass, got %v", err) + } + // Only the `started` edge (web→cache) is unchecked; postgres has a check → + // no healthy edge violates. + hasHC := func(n Node) bool { return n.Shared } // shared (postgres) has one; cache doesn't + if err := g.RequireHealthchecks(hasHC); err != nil { + t.Errorf("started-condition edge to a no-healthcheck service must be allowed, got %v", err) + } +}