Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ tmp*
.env
.idea
.DS_Store
.tool-versions
2 changes: 1 addition & 1 deletion k8s-deployer/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
node_modules/
dist/
tmp/
coverage/
coverage/
210 changes: 210 additions & 0 deletions k8s-deployer/src/dependency-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { Schema } from "./model.js"
import {
CyclicDependencyError,
InvalidDependencyError,
SelfDependencyError,
DuplicateComponentIdError,
DependencyValidationError
} from "./errors.js"

export interface TopologicalSortResult {
sortedComponents: Array<Schema.DeployableComponent>
levels: Array<Array<Schema.DeployableComponent>> // Components grouped by dependency level
}

/**
* Validate all dependencies for a set of components and throws appropriate errors
* Validation checks the following:
* 1. Cyclic dependencies
* 2. Self-dependencies
* 3. Invalid references
* 4. Duplicate component IDs
*/
export const validateDependencies = (
components: Array<Schema.DeployableComponent>,
testSuiteName: string
): void => {
const errors: Array<Error> = []

// Check for duplicate component IDs
const componentIdCount = new Map<string, number>()
components.forEach(({ id }) => {
componentIdCount.set(id, (componentIdCount.get(id) ?? 0) + 1)
if (componentIdCount.get(id) > 1) {
errors.push(new DuplicateComponentIdError(id))
}
})

// Check for invalid component ID references and self-dependencies
components.forEach(component => {
const { id, dependsOn } = component
if (dependsOn) {
dependsOn.forEach(depId => {
if (depId === id) {
errors.push(new SelfDependencyError(id))
}
if (!componentIdCount.has(depId)) {
errors.push(new InvalidDependencyError(id, depId))
}
})
}
})

// Check for cyclic dependencies
const cyclicError = detectCyclicDependencies(components)
if (cyclicError) {
errors.push(cyclicError)
}

// Throw all errors found
if (errors.length > 0) {
throw new DependencyValidationError(testSuiteName, errors)
}
}

/**
* Detect cyclic dependencies using DFS
*/
export const detectCyclicDependencies = (components: Array<Schema.DeployableComponent>): CyclicDependencyError | undefined => {
const unvisited = 0, visiting = 1, visited = 2
const state = new Map<string, number>()
const parent = new Map<string, string>()
const componentMap = new Map<string, Schema.DeployableComponent>()

// Initialize
components.forEach(comp => {
componentMap.set(comp.id, comp)
state.set(comp.id, unvisited)
})

const dfs = (componentId: string): CyclicDependencyError | undefined => {
if (state.get(componentId) === visited) {
return undefined
}

// Found a cycle. Include the cycle path in the error
if (state.get(componentId) === visiting) {
return new CyclicDependencyError(reconstructCyclePath(componentId, parent), componentId)
}

state.set(componentId, visiting)

const component = componentMap.get(componentId)
if (!component) {
return undefined
}
if (component.dependsOn) {
for (const depId of component.dependsOn) {
parent.set(depId, componentId)
const result = dfs(depId)
if (result) {
return result
}
}
}

state.set(componentId, visited)
}

// Check all components
for (const component of components) {
if (state.get(component.id) === unvisited) {
const result = dfs(component.id)
if (result) {
return result
}
}
}
}

/**
* Perform topological sort
* Preserve original order for components at the same dependency level
*/
export const topologicalSort = (components: Array<Schema.DeployableComponent>): TopologicalSortResult => {
// Build adjacency list and in-degree count
const graph = new Map<string, string[]>()
const inDegreeMap = new Map<string, number>()
const componentMap = new Map<string, Schema.DeployableComponent>()

// Initialize
components.forEach(comp => {
componentMap.set(comp.id, comp)
graph.set(comp.id, [])
inDegreeMap.set(comp.id, 0)
})

// Build graph and calculate in-degrees
components.forEach(({ id, dependsOn }) =>
dependsOn?.forEach(depId => {
graph.get(depId)?.push(id)
inDegreeMap.set(id, (inDegreeMap.get(id) ?? 0) + 1)
})
)

// Find components with in-degree = 0
const queue: string[] = []
inDegreeMap.forEach((degree, id) => { if (degree === 0) queue.push(id) })

// Sort components using BFS
const result: Schema.DeployableComponent[] = []
const levels: Schema.DeployableComponent[][] = []

while (queue.length > 0) {
const currentLevel: Schema.DeployableComponent[] = []
const currentLevelSize = queue.length

// Process all components at current level
for (let i = 0; i < currentLevelSize; i++) {
const currentId = queue.shift()!
const component = componentMap.get(currentId)!
currentLevel.push(component)

// Update in-degrees of dependent components
graph.get(currentId)?.forEach(depId => {
const newDegree = (inDegreeMap.get(depId) ?? 0) - 1
inDegreeMap.set(depId, newDegree)
if (newDegree === 0) {
queue.push(depId)
}
})
}

// Sort current level by original order defined in the pitfile
// The deployment order on the same dependency level follows the component definition order
currentLevel.sort((a, b) => {
const aIndex = components.findIndex(c => c.id === a.id)
const bIndex = components.findIndex(c => c.id === b.id)
return aIndex - bIndex
})

// Add sorted components to result
currentLevel.forEach(component => result.push(component))
levels.push([...currentLevel])
}

return { sortedComponents: result, levels }
}

/**
* Return components in reverse order for undeployment
* Only reverse dependency levels but maintain original component definition order within each level
* The undeployment order on the same dependency level follows the component definition order
*/
export const reverseTopologicalSort = (sortResult: TopologicalSortResult): Array<Schema.DeployableComponent> => [...sortResult.levels].reverse().flat()

/**
* Traceback the dependency path when a cycle is detected
* This is for troubleshooting convenience
*/
const reconstructCyclePath = (startId: string, parent: Map<string, string>): Array<string> => {
const path: Array<string> = []
let current = startId

do {
path.push(current)
current = parent.get(current)!
} while (current !== startId)

return path
}
80 changes: 70 additions & 10 deletions k8s-deployer/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,75 @@
export class SchemaValidationError extends Error {
constructor(message: string) {
super(`SchemaValidationError: ${message}`);
export class CyclicDependencyError extends Error {
public readonly cyclePath: Array<string>
public readonly componentId: string

constructor(cyclePath: Array<string>, componentId: string) {
const cycleString = cyclePath.join(' → ')
super(`Cyclic dependency detected: ${cycleString} → ${componentId}`)
this.name = "CyclicDependencyError"
this.cyclePath = cyclePath
this.componentId = componentId
}
}

export class InvalidDependencyError extends Error {
public readonly componentId: string
public readonly invalidDependency: string

constructor(componentId: string, invalidDependency: string) {
super(`Component ${componentId} references non-existent component ${invalidDependency}`)
this.name = "InvalidDependencyError"
this.componentId = componentId
this.invalidDependency = invalidDependency
}
}

export class SelfDependencyError extends Error {
public readonly componentId: string

constructor(componentId: string) {
super(`Component ${componentId} cannot depend on itself`)
this.name = "SelfDependencyError"
this.componentId = componentId
}
}

export class ApiSchemaValidationError extends SchemaValidationError {
data?: string
url: string
constructor(message: string, url: string, data?: any) {
super(message);
this.url = url
this.data = data
export class DuplicateComponentIdError extends Error {
public readonly componentId: string

constructor(componentId: string) {
super(`Duplicate component ID ${componentId} found`)
this.name = "DuplicateComponentIdError"
this.componentId = componentId
}
}

export class DependencyValidationError extends Error {
public readonly errors: Array<Error>
public readonly testSuiteName: string

constructor(testSuiteName: string, errors: Array<Error>) {
const errorMessages = errors.map(e => e.message).join('; ')
super(`Dependency validation failed for test suite ${testSuiteName}: ${errorMessages}`)
this.name = "DependencyValidationError"
this.errors = errors
this.testSuiteName = testSuiteName
}
}

export class ApiSchemaValidationError extends Error {
public readonly validationErrors: string
public readonly endpoint: string
public readonly response: string
public readonly url: string
public readonly data?: string

constructor(validationErrors: string, endpoint: string, response: string) {
super(`API schema validation failed for endpoint ${endpoint}: ${validationErrors}`)
this.name = "ApiSchemaValidationError"
this.validationErrors = validationErrors
this.endpoint = endpoint
this.response = response
this.url = endpoint
this.data = response
}
}
40 changes: 40 additions & 0 deletions k8s-deployer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Config } from "./config.js"
import * as PifFileLoader from "./pitfile/pitfile-loader.js"
import * as SuiteHandler from "./test-suite-handler.js"
import { DeployedTestSuite } from "./model.js"
import { validateDependencies } from "./dependency-resolver.js"
import { DependencyValidationError, CyclicDependencyError } from "./errors.js"

const main = async () => {
logger.info("main()...")
Expand All @@ -13,6 +15,44 @@ const main = async () => {

const file = await PifFileLoader.loadFromFile(config.pitfile)

// EARLY VALIDATION: Check all test suites for dependency issues
logger.info("")
logger.info("--------------------- Validating Component Dependencies ---------------------")
logger.info("")

for (let i = 0; i < file.testSuites.length; i++) {
const testSuite = file.testSuites[i]

try {
validateDependencies(testSuite.deployment.graph.components, testSuite.name)
logger.info("Test suite '%s' dependencies validated successfully", testSuite.name)
} catch (error) {
if (error instanceof DependencyValidationError) {
// Log dependency validation errors
logger.error("")
logger.error("DEPENDENCY VALIDATION FAILED for test suite '%s'", testSuite.name)
logger.error("")

error.errors.forEach(err => {
if (err instanceof CyclicDependencyError) {
logger.error("CYCLIC DEPENDENCY DETECTED:")
logger.error("Cycle: %s", err.cyclePath.join(' → '))
logger.error("This creates an infinite loop and cannot be resolved.")
logger.error("Please fix the dependency chain in your pitfile.yml")
} else {
logger.error("%s", err.message)
}
})

logger.error("")
logger.error("DEPLOYMENT ABORTED: Fix dependency issues before proceeding")
logger.error("")
}

throw error
}
}

const artefacts = new Array<Array<DeployedTestSuite>>()
for (let i = 0; i < file.testSuites.length; i++) {
const testSuite = file.testSuites[i]
Expand Down
1 change: 1 addition & 0 deletions k8s-deployer/src/pitfile/schema-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export class DeployableComponent {
deploy: DeployInstructions
undeploy: DeployInstructions
logTailing?: LogTailing
dependsOn?: Array<string> // Optional array of component IDs this component depends on
}

export class Graph {
Expand Down
Loading