Skip to content

Commit d3711c7

Browse files
feat: [KSBP-101776] - Unify component dependency graph and parallel deployment instruction
1 parent 013055f commit d3711c7

5 files changed

Lines changed: 168 additions & 14 deletions

File tree

k8s-deployer/src/dependency-resolver.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,28 @@ const reconstructCyclePath = (startId: string, parent: Map<string, string>): Arr
208208

209209
return path
210210
}
211+
212+
/**
213+
* Print the dependency graph in a visually grouped format.
214+
* Components that can be deployed in parallel are shown together and annotated.
215+
*/
216+
export const printDependencyGraph = (components: Array<Schema.DeployableComponent>): void => {
217+
const { levels } = topologicalSort(components)
218+
const sep = "─".repeat(40)
219+
const edges = components.flatMap(c => (c.dependsOn ?? []).map(dep => ` ${dep} ──▶ ${c.id}`))
220+
221+
console.log("Dependency Graph")
222+
console.log(sep)
223+
levels.forEach((level, idx) =>
224+
console.log(` Stage ${idx + 1}${level.map(c => c.parallel ? `${c.id} ⚡` : c.id).join(" ")}`)
225+
)
226+
if (edges.length > 0) {
227+
console.log(sep)
228+
edges.forEach(e => console.log(e))
229+
}
230+
if (components.some(c => c.parallel)) {
231+
console.log(sep)
232+
console.log(" ⚡ = deployed concurrently within stage")
233+
}
234+
console.log(sep)
235+
}

k8s-deployer/src/pitfile/schema-v1.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export class DeployableComponent {
6161
undeploy: DeployInstructions
6262
logTailing?: LogTailing
6363
dependsOn?: Array<string> // Optional array of component IDs this component depends on
64+
parallel?: boolean // If true, this component may be deployed concurrently with other parallel components at the same dependency level
6465
}
6566

6667
export class Graph {

k8s-deployer/src/test-suite-handler.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as PifFileLoader from "./pitfile/pitfile-loader.js"
99
import { PodLogTail } from "./pod-log-tail.js"
1010
import * as Shell from "./shell-facade.js"
1111
import * as TestRunner from "./test-app-client/test-runner.js"
12-
import { topologicalSort, reverseTopologicalSort } from "./dependency-resolver.js"
12+
import { topologicalSort, reverseTopologicalSort, printDependencyGraph } from "./dependency-resolver.js"
1313

1414
export const generatePrefix = (env: string): Prefix => {
1515
return generatePrefixByDate(new Date(), env)
@@ -37,27 +37,55 @@ export const generatePrefixByDate = (date: Date, env: string): Prefix => {
3737

3838
/**
3939
* Deploying:
40-
* 1. all components in the graph in the topological order
40+
* 1. all components in the graph in topological order, deploying parallel-flagged components concurrently within each level
4141
* 2. test app for the graph.
4242
*/
4343
const deployGraph = async (config: Config, workspace: string, testSuiteId: string, graph: Schema.Graph, namespace: Namespace, testAppDirForRemoteTestSuite?: string): Promise<GraphDeploymentResult> => {
4444
// Dependencies are already validated in main(), so it's safe to directly sort here.
45-
const { sortedComponents } = topologicalSort(graph.components)
45+
const { sortedComponents, levels } = topologicalSort(graph.components)
4646

4747
logger.info("")
4848
logger.info("Dependency Resolution for %s:", testSuiteId)
4949
logger.info("Deployment order: %s", sortedComponents.map(c => c.id).join(" → "))
5050
logger.info("")
5151

52-
// Deploy components in topological order
53-
const deployments: Array<DeployedComponent> = new Array()
54-
for (let i = 0; i < sortedComponents.length; i++) {
55-
const componentSpec = sortedComponents[i]
56-
logger.info("")
57-
logger.info("Deploying graph component (%s of %s) \"%s\" for suite \"%s\"...", i + 1, sortedComponents.length, componentSpec.name, testSuiteId)
58-
logger.info("")
59-
const commitSha = await Deployer.deployComponent(config, workspace, componentSpec, namespace)
60-
deployments.push(new DeployedComponent(commitSha, componentSpec))
52+
logger.info("")
53+
logger.info("Dependency Graph for %s:", testSuiteId)
54+
printDependencyGraph(graph.components)
55+
logger.info("")
56+
57+
// Deploy components level by level. Within each level, components with parallel:true are deployed concurrently.
58+
const deployments: Array<DeployedComponent> = []
59+
let componentIndex = 0
60+
for (const level of levels) {
61+
const parallelGroup = level.filter(c => c.parallel === true)
62+
const sequentialGroup = level.filter(c => c.parallel !== true)
63+
64+
// Deploy all parallel-flagged components in this level concurrently
65+
if (parallelGroup.length > 0) {
66+
logger.info("")
67+
logger.info("Deploying %d component(s) in parallel for suite \"%s\": %s", parallelGroup.length, testSuiteId, parallelGroup.map(c => c.id).join(", "))
68+
const parallelResults = await Promise.all(
69+
parallelGroup.map(async componentSpec => {
70+
const idx = ++componentIndex
71+
logger.info("Deploying graph component (%s of %s) \"%s\" for suite \"%s\"...", idx, sortedComponents.length, componentSpec.name, testSuiteId)
72+
const commitSha = await Deployer.deployComponent(config, workspace, componentSpec, namespace)
73+
logger.info("Graph component \"%s\" for suite \"%s\" deployed.", componentSpec.name, testSuiteId)
74+
return new DeployedComponent(commitSha, componentSpec)
75+
})
76+
)
77+
deployments.push(...parallelResults)
78+
}
79+
80+
// Deploy sequential components one by one
81+
for (const componentSpec of sequentialGroup) {
82+
const idx = ++componentIndex
83+
logger.info("")
84+
logger.info("Deploying graph component (%s of %s) \"%s\" for suite \"%s\"...", idx, sortedComponents.length, componentSpec.name, testSuiteId)
85+
logger.info("")
86+
const commitSha = await Deployer.deployComponent(config, workspace, componentSpec, namespace)
87+
deployments.push(new DeployedComponent(commitSha, componentSpec))
88+
}
6189
}
6290
logger.info("")
6391

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

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
validateDependencies,
55
detectCyclicDependencies,
66
topologicalSort,
7-
reverseTopologicalSort
7+
reverseTopologicalSort,
8+
printDependencyGraph
89
} from "../src/dependency-resolver.js"
910
import { Schema } from "../src/model.js"
1011
import {
@@ -220,6 +221,7 @@ describe("Dependency Resolver", () => {
220221
})
221222

222223
describe("topologicalSort", () => {
224+
223225
it("should sort components without dependencies in original order", () => {
224226
const components: Array<Schema.DeployableComponent> = [
225227
{
@@ -249,6 +251,35 @@ describe("Dependency Resolver", () => {
249251
expect(result.sortedComponents.map(c => c.id)).to.deep.equal(["component-a", "component-b", "component-c"])
250252
})
251253

254+
it("should preserve original order if no dependsOn fields are present", () => {
255+
const components: Array<Schema.DeployableComponent> = [
256+
{
257+
name: "Component X",
258+
id: "component-x",
259+
location: { type: Schema.LocationType.Local },
260+
deploy: { command: "deploy.sh" },
261+
undeploy: { command: "undeploy.sh" }
262+
},
263+
{
264+
name: "Component Y",
265+
id: "component-y",
266+
location: { type: Schema.LocationType.Local },
267+
deploy: { command: "deploy.sh" },
268+
undeploy: { command: "undeploy.sh" }
269+
},
270+
{
271+
name: "Component Z",
272+
id: "component-z",
273+
location: { type: Schema.LocationType.Local },
274+
deploy: { command: "deploy.sh" },
275+
undeploy: { command: "undeploy.sh" }
276+
}
277+
]
278+
279+
const result = topologicalSort(components)
280+
expect(result.sortedComponents.map(c => c.id)).to.deep.equal(["component-x", "component-y", "component-z"])
281+
})
282+
252283
it("should sort components with dependencies correctly", () => {
253284
const components: Array<Schema.DeployableComponent> = [
254285
{
@@ -360,4 +391,72 @@ describe("Dependency Resolver", () => {
360391
expect(reverseResult.map(c => c.id)).to.deep.equal(["frontend", "api-service", "cache", "database"])
361392
})
362393
})
394+
395+
describe("printDependencyGraph", () => {
396+
let logOutput: string[]
397+
let originalLog: typeof console.log
398+
beforeEach(() => {
399+
logOutput = []
400+
originalLog = console.log
401+
console.log = (msg?: any) => logOutput.push(String(msg))
402+
})
403+
afterEach(() => {
404+
console.log = originalLog
405+
})
406+
407+
it("prints graph for components without dependencies", () => {
408+
const components: Array<Schema.DeployableComponent> = [
409+
{ name: "A", id: "a", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" } },
410+
{ name: "B", id: "b", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" } },
411+
{ name: "C", id: "c", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" } }
412+
]
413+
printDependencyGraph(components)
414+
expect(logOutput[0]).to.equal("Dependency Graph")
415+
expect(logOutput).to.include(" Stage 1 │ a b c")
416+
})
417+
418+
it("prints graph for components with dependencies at multiple stages", () => {
419+
const components: Array<Schema.DeployableComponent> = [
420+
{ name: "DB", id: "db", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" }, dependsOn: [] },
421+
{ name: "API", id: "api", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" }, dependsOn: ["db"] },
422+
{ name: "Cache", id: "cache", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" }, dependsOn: ["db"] }
423+
]
424+
printDependencyGraph(components)
425+
expect(logOutput[0]).to.equal("Dependency Graph")
426+
expect(logOutput).to.include(" Stage 1 │ db")
427+
expect(logOutput).to.include(" Stage 2 │ api cache")
428+
expect(logOutput).to.include(" db ──▶ api")
429+
expect(logOutput).to.include(" db ──▶ cache")
430+
})
431+
432+
it("prints graph for chained dependencies", () => {
433+
const components: Array<Schema.DeployableComponent> = [
434+
{ name: "A", id: "a", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" }, dependsOn: [] },
435+
{ name: "B", id: "b", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" }, dependsOn: ["a"] },
436+
{ name: "C", id: "c", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" }, dependsOn: ["b"] }
437+
]
438+
printDependencyGraph(components)
439+
expect(logOutput[0]).to.equal("Dependency Graph")
440+
expect(logOutput).to.include(" Stage 1 │ a")
441+
expect(logOutput).to.include(" Stage 2 │ b")
442+
expect(logOutput).to.include(" Stage 3 │ c")
443+
expect(logOutput).to.include(" a ──▶ b")
444+
expect(logOutput).to.include(" b ──▶ c")
445+
})
446+
447+
it("annotates parallel components with ⚡ and prints a legend", () => {
448+
const components: Array<Schema.DeployableComponent> = [
449+
{ name: "A", id: "a", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" } },
450+
{ name: "B", id: "b", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" }, dependsOn: ["a"], parallel: true },
451+
{ name: "C", id: "c", location: { type: Schema.LocationType.Local }, deploy: { command: "deploy.sh" }, undeploy: { command: "undeploy.sh" }, dependsOn: ["a"], parallel: true }
452+
]
453+
printDependencyGraph(components)
454+
expect(logOutput[0]).to.equal("Dependency Graph")
455+
expect(logOutput).to.include(" Stage 1 │ a")
456+
expect(logOutput).to.include(" Stage 2 │ b ⚡ c ⚡")
457+
expect(logOutput).to.include(" a ──▶ b")
458+
expect(logOutput).to.include(" a ──▶ c")
459+
expect(logOutput).to.include(" ⚡ = deployed concurrently within stage")
460+
})
461+
})
363462
})

k8s-deployer/test/test-suite-handler.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,8 @@ describe("Deployment happy path", async () => {
359359
.returns(
360360
{
361361
ok: true,
362-
json: async () => { return new webapi.ReportResponse("session-2", testSuiteId, webapi.TestStatus.COMPLETED, report) }
362+
// Serialize then parse to simulate real HTTP JSON: Date objects become ISO strings
363+
json: async () => JSON.parse(JSON.stringify(new webapi.ReportResponse("session-2", testSuiteId, webapi.TestStatus.COMPLETED, report)))
363364
}
364365
)
365366

0 commit comments

Comments
 (0)