Skip to content

Commit 9f51480

Browse files
feat: [KSBP-10176] - Pretty mermaid diagrams
1 parent 7e1c479 commit 9f51480

8 files changed

Lines changed: 803 additions & 37 deletions

k8s-deployer/package-lock.json

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

k8s-deployer/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"ajv": "^8.12.0",
4141
"express": "^4.19.2",
4242
"express-openapi-validator": "^5.1.6",
43+
"mermaid-ascii": "^1.0.0",
4344
"node-fetch": "^3.3.2",
4445
"swagger-ui-express": "^5.0.0",
4546
"uuid": "^9.0.1",

k8s-deployer/src/dependency-resolver.ts

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Schema } from "./model.js"
2+
import { mermaidToAscii } from "mermaid-ascii"
23
import {
34
CyclicDependencyError,
45
InvalidDependencyError,
@@ -209,58 +210,100 @@ const reconstructCyclePath = (startId: string, parent: Map<string, string>): Arr
209210
return path
210211
}
211212

213+
// Returns the mermaid node identifier for a component.
214+
// Parallel components get a _🔀 suffix so the box label shows the flag.
215+
const nodeId = (c: Schema.DeployableComponent): string =>
216+
c.deploy.parallel === true ? `${c.id}_🔀` : c.id
217+
218+
// Build a `graph TD` mermaid source string from the sorted levels + testApp.
219+
const buildMermaidSrc = (
220+
levels: Array<Array<Schema.DeployableComponent>>,
221+
components: Array<Schema.DeployableComponent>,
222+
testApp: Schema.DeployableComponent
223+
): string => {
224+
const componentMap = new Map(components.map(c => [c.id, c]))
225+
const edgeLines: string[] = []
226+
227+
// Edges from dependsOn relationships
228+
components.forEach(c => {
229+
(c.dependsOn ?? []).forEach(depId => {
230+
const dep = componentMap.get(depId)!
231+
edgeLines.push(` ${nodeId(dep)} --> ${nodeId(c)}`)
232+
})
233+
})
234+
235+
// Sequential testApp: connect every last-level component to the testApp node
236+
if (testApp.deploy.parallel !== true && levels.length > 0) {
237+
levels[levels.length - 1].forEach(c => {
238+
edgeLines.push(` ${nodeId(c)} --> ${testApp.id}`)
239+
})
240+
}
241+
242+
// Nodes that appear in no edge must be declared explicitly or they won't render
243+
const edgeText = edgeLines.join("\n")
244+
const allNodes = [
245+
...components,
246+
...(testApp.deploy.parallel !== true ? [testApp] : [])
247+
]
248+
const isolatedDeclarations = allNodes
249+
.filter(c => !edgeText.includes(nodeId(c)))
250+
.map(c => ` ${nodeId(c)}`)
251+
252+
return ["graph TD", ...isolatedDeclarations, ...edgeLines].join("\n")
253+
}
254+
255+
// Format a single dependency level into a display string.
256+
// Parallel components are grouped in brackets; sequential follow after an arrow.
257+
// e.g. "[B 🔀 C 🔀] → D E" or "[B 🔀] → C" or "A B" (all sequential)
258+
const formatLevel = (level: Array<Schema.DeployableComponent>): string => {
259+
const parallel = level.filter(c => c.deploy.parallel === true)
260+
const sequential = level.filter(c => c.deploy.parallel !== true)
261+
const parallelPart = parallel.length > 0 ? `[${parallel.map(c => `${c.id} 🔀`).join(" ")}]` : ""
262+
const sequentialPart = sequential.map(c => c.id).join(" ")
263+
if (parallelPart && sequentialPart) return `${parallelPart}${sequentialPart}`
264+
return parallelPart || sequentialPart
265+
}
266+
212267
/**
213268
* Print the full deployment graph including testApp placement.
214269
*
215-
* - Components are shown in topological stages.
216-
* - If testApp.deploy.parallel === true it is shown in a separate concurrent section
217-
* (it runs alongside all component stages).
218-
* - Otherwise testApp is shown as the final sequential stage after all components.
270+
* Outputs two representations:
271+
* 1. Stage list — "Stage N │ [parallel 🔀] → sequential" text summary
272+
* 2. ASCII art diagram via mermaid-ascii (parallel components labelled with _🔀)
273+
*
274+
* If testApp.deploy.parallel === true it is shown in a concurrent banner rather
275+
* than as a stage/diagram node.
219276
*/
220277
export const printDependencyGraph = (graph: Schema.Graph): void => {
221278
const { components, testApp } = graph
222279
const { levels } = topologicalSort(components)
223280
const sep = "─".repeat(40)
224-
const edges = components.flatMap(c => (c.dependsOn ?? []).map(dep => ` ${dep} ──▶ ${c.id}`))
225-
const anyConcurrent = components.some(c => c.deploy.parallel) || testApp.deploy.parallel === true
226-
227-
// Format a single dependency level into a display string.
228-
// Parallel components are grouped in brackets; sequential follow after an arrow.
229-
// e.g. "[B 🔀 C 🔀] → D E" or "[B 🔀] → C" or "A B" (all sequential)
230-
const formatLevel = (level: Array<Schema.DeployableComponent>): string => {
231-
const parallel = level.filter(c => c.deploy.parallel === true)
232-
const sequential = level.filter(c => c.deploy.parallel !== true)
233-
const parallelPart = parallel.length > 0 ? `[${parallel.map(c => `${c.id} 🔀`).join(" ")}]` : ""
234-
const sequentialPart = sequential.map(c => c.id).join(" ")
235-
if (parallelPart && sequentialPart) return `${parallelPart}${sequentialPart}`
236-
return parallelPart || sequentialPart
237-
}
238281

239282
console.log("Dependency Graph")
240283
console.log(sep)
241284

285+
// ── Stage list ────────────────────────────────────────────────────────────
242286
if (testApp.deploy.parallel === true) {
243-
// testApp runs concurrently with the entire component chain — show it in a separate section
244287
levels.forEach((level, idx) =>
245288
console.log(` Stage ${idx + 1}${formatLevel(level)}`)
246289
)
247290
console.log(sep)
248291
console.log(` ${testApp.id} 🔀 (concurrent with component stages)`)
249292
} else {
250-
// testApp runs after all components — append it as the final stage
251293
levels.forEach((level, idx) =>
252294
console.log(` Stage ${idx + 1}${formatLevel(level)}`)
253295
)
254296
console.log(` Stage ${levels.length + 1}${testApp.id}`)
255297
}
256298

257-
if (edges.length > 0) {
258-
console.log(sep)
259-
edges.forEach(e => console.log(e))
260-
}
261-
if (anyConcurrent) {
262-
console.log(sep)
263-
console.log(" 🔀 = concurrent deployment")
264-
}
299+
// ── ASCII art diagram ─────────────────────────────────────────────────────
300+
console.log(sep)
301+
const mermaidSrc = buildMermaidSrc(levels, components, testApp)
302+
const originalDebug = console.debug
303+
console.debug = () => {}
304+
const asciiArt = mermaidToAscii(mermaidSrc)
305+
console.debug = originalDebug
306+
asciiArt.split("\n").forEach(line => console.log(line))
307+
265308
console.log(sep)
266309
}

k8s-deployer/test/component-dependency.spec.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,168 @@ describe("Component Dependency Tests", () => {
7979
})
8080
})
8181

82+
describe("Parallel Deploy Flag", () => {
83+
it("should load and sort components with parallel deploy flag set", async () => {
84+
const pitfilePath = path.join(__dirname, "pitfile", "test-pitfile-valid-with-parallel-deploy.yml")
85+
const pitfile = await PifFileLoader.loadFromFile(pitfilePath)
86+
const testSuite = pitfile.testSuites[0]
87+
const components = testSuite.deployment.graph.components
88+
89+
// Should validate successfully
90+
expect(() => validateDependencies(components, testSuite.name)).not.to.throw()
91+
92+
const sortResult = topologicalSort(components)
93+
94+
// Level 0: no dependencies — database, message-queue, config-service, metrics-collector
95+
expect(sortResult.levels[0].map(c => c.id)).to.deep.equal([
96+
"database", "message-queue", "config-service", "metrics-collector"
97+
])
98+
99+
// Level 1: api-service (depends on database + message-queue), cache-service (depends on database)
100+
expect(sortResult.levels[1].map(c => c.id)).to.deep.equal(["api-service", "cache-service"])
101+
102+
// Level 2: backend-for-frontend (depends on api-service + cache-service)
103+
expect(sortResult.levels[2].map(c => c.id)).to.deep.equal(["backend-for-frontend"])
104+
105+
// Level 3: frontend (depends on backend-for-frontend)
106+
expect(sortResult.levels[3].map(c => c.id)).to.deep.equal(["frontend"])
107+
108+
// Parallel flags are preserved through sorting
109+
const db = components.find(c => c.id === "database")!
110+
const configService = components.find(c => c.id === "config-service")!
111+
const apiService = components.find(c => c.id === "api-service")!
112+
const cacheService = components.find(c => c.id === "cache-service")!
113+
114+
expect(db.deploy.parallel).to.be.true
115+
expect(apiService.deploy.parallel).to.be.true
116+
expect(configService.deploy.parallel).to.be.undefined // sequential
117+
expect(cacheService.deploy.parallel).to.be.undefined // sequential, despite depending on a parallel component
118+
})
119+
120+
it("testApp parallel flag is independent of component parallel flags", async () => {
121+
const pitfilePath = path.join(__dirname, "pitfile", "test-pitfile-valid-with-parallel-deploy.yml")
122+
const pitfile = await PifFileLoader.loadFromFile(pitfilePath)
123+
const testSuite = pitfile.testSuites[0]
124+
125+
expect(testSuite.deployment.graph.testApp.deploy.parallel).to.be.true
126+
})
127+
128+
it("multi-stage graph: mixed parallel/sequential at every level, cross-stage ancestry, fan-in convergence", async () => {
129+
const pitfilePath = path.join(__dirname, "pitfile", "test-pitfile-valid-with-parallel-subgroups.yml")
130+
const pitfile = await PifFileLoader.loadFromFile(pitfilePath)
131+
// Suite index 1: multi-stage-mixed
132+
const testSuite = pitfile.testSuites[1]
133+
const components = testSuite.deployment.graph.components
134+
135+
expect(() => validateDependencies(components, testSuite.name)).not.to.throw()
136+
137+
const sortResult = topologicalSort(components)
138+
expect(sortResult.levels).to.have.length(5)
139+
140+
// Stage 0: two independent roots — Infra (parallel) and Config (sequential)
141+
expect(sortResult.levels[0].map(c => c.id)).to.deep.equal(["infra", "config"])
142+
expect(components.find(c => c.id === "infra")!.deploy.parallel).to.be.true
143+
expect(components.find(c => c.id === "config")!.deploy.parallel).to.be.undefined
144+
145+
// Stage 1: Auth🔀, Cache🔀 depend only on Infra; Registry depends on both roots (sequential)
146+
expect(sortResult.levels[1].map(c => c.id)).to.deep.equal(["auth", "cache", "registry"])
147+
expect(components.find(c => c.id === "auth")!.deploy.parallel).to.be.true
148+
expect(components.find(c => c.id === "cache")!.deploy.parallel).to.be.true
149+
expect(components.find(c => c.id === "registry")!.deploy.parallel).to.be.undefined
150+
// Registry's cross-stage ancestry: depends on both stage-0 nodes
151+
expect(components.find(c => c.id === "registry")!.dependsOn).to.deep.equal(["infra", "config"])
152+
153+
// Stage 2: API🔀 (Auth+Cache), Worker🔀 (Cache+Registry — one parallel, one sequential parent), Scheduler (Registry)
154+
expect(sortResult.levels[2].map(c => c.id)).to.deep.equal(["api", "worker", "scheduler"])
155+
expect(components.find(c => c.id === "api")!.deploy.parallel).to.be.true
156+
expect(components.find(c => c.id === "worker")!.deploy.parallel).to.be.true
157+
expect(components.find(c => c.id === "scheduler")!.deploy.parallel).to.be.undefined
158+
expect(components.find(c => c.id === "worker")!.dependsOn).to.deep.equal(["cache", "registry"])
159+
160+
// Stage 3: Gateway — sequential fan-in from all three stage-2 nodes
161+
expect(sortResult.levels[3].map(c => c.id)).to.deep.equal(["gateway"])
162+
expect(components.find(c => c.id === "gateway")!.deploy.parallel).to.be.undefined
163+
expect(components.find(c => c.id === "gateway")!.dependsOn).to.deep.equal(["api", "worker", "scheduler"])
164+
165+
// Stage 4: Frontend🔀 and Admin🔀 both depend on Gateway
166+
expect(sortResult.levels[4].map(c => c.id)).to.deep.equal(["frontend", "admin"])
167+
expect(components.find(c => c.id === "frontend")!.deploy.parallel).to.be.true
168+
expect(components.find(c => c.id === "admin")!.deploy.parallel).to.be.true
169+
170+
// Undeployment: reverse stages, within each stage order is preserved
171+
const undeployOrder = reverseTopologicalSort(sortResult).map(c => c.id)
172+
expect(undeployOrder).to.deep.equal([
173+
"frontend", "admin",
174+
"gateway",
175+
"api", "worker", "scheduler",
176+
"auth", "cache", "registry",
177+
"infra", "config"
178+
])
179+
180+
// testApp is marked parallel (runs concurrently with component stages)
181+
expect(testSuite.deployment.graph.testApp.deploy.parallel).to.be.true
182+
})
183+
184+
it("A -> [B🔀, C🔀, D🔀] -> E: all middle components parallel, flanked by sequential nodes", async () => {
185+
const pitfilePath = path.join(__dirname, "pitfile", "test-pitfile-valid-with-parallel-subgroups.yml")
186+
const pitfile = await PifFileLoader.loadFromFile(pitfilePath)
187+
// Suite index 0: all-parallel-middle
188+
const testSuite = pitfile.testSuites[0]
189+
const components = testSuite.deployment.graph.components
190+
191+
expect(() => validateDependencies(components, testSuite.name)).not.to.throw()
192+
193+
const sortResult = topologicalSort(components)
194+
195+
// Level 0: A (no dependencies, sequential)
196+
expect(sortResult.levels[0].map(c => c.id)).to.deep.equal(["node-a"])
197+
expect(components.find(c => c.id === "node-a")!.deploy.parallel).to.be.undefined
198+
199+
// Level 1: B, C, D (all depend on A, all parallel)
200+
expect(sortResult.levels[1].map(c => c.id)).to.deep.equal(["node-b", "node-c", "node-d"])
201+
expect(components.find(c => c.id === "node-b")!.deploy.parallel).to.be.true
202+
expect(components.find(c => c.id === "node-c")!.deploy.parallel).to.be.true
203+
expect(components.find(c => c.id === "node-d")!.deploy.parallel).to.be.true
204+
205+
// Level 2: E (depends on B, C, D — sequential)
206+
expect(sortResult.levels[2].map(c => c.id)).to.deep.equal(["node-e"])
207+
expect(components.find(c => c.id === "node-e")!.deploy.parallel).to.be.undefined
208+
209+
// Undeployment reverses: E -> B,C,D -> A
210+
const undeployOrder = reverseTopologicalSort(sortResult).map(c => c.id)
211+
expect(undeployOrder).to.deep.equal(["node-e", "node-b", "node-c", "node-d", "node-a"])
212+
})
213+
214+
it("A -> [B🔀, C🔀, D] -> E: mixed parallel/sequential middle layer, E depends on all three", async () => {
215+
const pitfilePath = path.join(__dirname, "pitfile", "test-pitfile-valid-with-parallel-subgroups.yml")
216+
const pitfile = await PifFileLoader.loadFromFile(pitfilePath)
217+
// Suite index 2: mixed-parallel-middle
218+
const testSuite = pitfile.testSuites[2]
219+
const components = testSuite.deployment.graph.components
220+
221+
expect(() => validateDependencies(components, testSuite.name)).not.to.throw()
222+
223+
const sortResult = topologicalSort(components)
224+
225+
// Level 0: A
226+
expect(sortResult.levels[0].map(c => c.id)).to.deep.equal(["node-a"])
227+
228+
// Level 1: B, C, D — same dependency level; B and C parallel, D sequential
229+
expect(sortResult.levels[1].map(c => c.id)).to.deep.equal(["node-b", "node-c", "node-d"])
230+
expect(components.find(c => c.id === "node-b")!.deploy.parallel).to.be.true
231+
expect(components.find(c => c.id === "node-c")!.deploy.parallel).to.be.true
232+
expect(components.find(c => c.id === "node-d")!.deploy.parallel).to.be.undefined // sequential
233+
234+
// Level 2: E (waits for all of B, C and D)
235+
expect(sortResult.levels[2].map(c => c.id)).to.deep.equal(["node-e"])
236+
expect(components.find(c => c.id === "node-e")!.deploy.parallel).to.be.undefined
237+
238+
// Undeployment reverses: E -> B,C,D -> A
239+
const undeployOrder = reverseTopologicalSort(sortResult).map(c => c.id)
240+
expect(undeployOrder).to.deep.equal(["node-e", "node-b", "node-c", "node-d", "node-a"])
241+
})
242+
})
243+
82244
describe("Complex Dependency Scenarios", () => {
83245
it("should handle complex dependency graphs correctly", async () => {
84246
const pitfilePath = path.join(__dirname, "pitfile", "test-pitfile-valid-with-complex-dependencies.yml")

0 commit comments

Comments
 (0)