diff --git a/.gitignore b/.gitignore index be713ce..91025a3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ tmp* .env .idea .DS_Store +.tool-versions \ No newline at end of file diff --git a/k8s-deployer/.gitignore b/k8s-deployer/.gitignore index f6eecb5..ec70ed0 100644 --- a/k8s-deployer/.gitignore +++ b/k8s-deployer/.gitignore @@ -1,4 +1,4 @@ node_modules/ dist/ tmp/ -coverage/ +coverage/ \ No newline at end of file diff --git a/k8s-deployer/src/dependency-resolver.ts b/k8s-deployer/src/dependency-resolver.ts new file mode 100644 index 0000000..50d3989 --- /dev/null +++ b/k8s-deployer/src/dependency-resolver.ts @@ -0,0 +1,210 @@ +import { Schema } from "./model.js" +import { + CyclicDependencyError, + InvalidDependencyError, + SelfDependencyError, + DuplicateComponentIdError, + DependencyValidationError +} from "./errors.js" + +export interface TopologicalSortResult { + sortedComponents: Array + levels: Array> // 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, + testSuiteName: string +): void => { + const errors: Array = [] + + // Check for duplicate component IDs + const componentIdCount = new Map() + 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): CyclicDependencyError | undefined => { + const unvisited = 0, visiting = 1, visited = 2 + const state = new Map() + const parent = new Map() + const componentMap = new Map() + + // 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): TopologicalSortResult => { + // Build adjacency list and in-degree count + const graph = new Map() + const inDegreeMap = new Map() + const componentMap = new Map() + + // 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 => [...sortResult.levels].reverse().flat() + +/** + * Traceback the dependency path when a cycle is detected + * This is for troubleshooting convenience + */ +const reconstructCyclePath = (startId: string, parent: Map): Array => { + const path: Array = [] + let current = startId + + do { + path.push(current) + current = parent.get(current)! + } while (current !== startId) + + return path +} diff --git a/k8s-deployer/src/errors.ts b/k8s-deployer/src/errors.ts index ffa1fea..ee2d318 100644 --- a/k8s-deployer/src/errors.ts +++ b/k8s-deployer/src/errors.ts @@ -1,15 +1,75 @@ -export class SchemaValidationError extends Error { - constructor(message: string) { - super(`SchemaValidationError: ${message}`); +export class CyclicDependencyError extends Error { + public readonly cyclePath: Array + public readonly componentId: string + + constructor(cyclePath: Array, 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 + public readonly testSuiteName: string + + constructor(testSuiteName: string, errors: Array) { + 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 } } \ No newline at end of file diff --git a/k8s-deployer/src/index.ts b/k8s-deployer/src/index.ts index 3de895d..fd91add 100644 --- a/k8s-deployer/src/index.ts +++ b/k8s-deployer/src/index.ts @@ -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()...") @@ -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>() for (let i = 0; i < file.testSuites.length; i++) { const testSuite = file.testSuites[i] diff --git a/k8s-deployer/src/pitfile/schema-v1.ts b/k8s-deployer/src/pitfile/schema-v1.ts index 97265f3..d040ea4 100644 --- a/k8s-deployer/src/pitfile/schema-v1.ts +++ b/k8s-deployer/src/pitfile/schema-v1.ts @@ -60,6 +60,7 @@ export class DeployableComponent { deploy: DeployInstructions undeploy: DeployInstructions logTailing?: LogTailing + dependsOn?: Array // Optional array of component IDs this component depends on } export class Graph { diff --git a/k8s-deployer/src/test-suite-handler.ts b/k8s-deployer/src/test-suite-handler.ts index 444c49e..75c2ed8 100644 --- a/k8s-deployer/src/test-suite-handler.ts +++ b/k8s-deployer/src/test-suite-handler.ts @@ -9,6 +9,7 @@ import * as PifFileLoader from "./pitfile/pitfile-loader.js" import { PodLogTail } from "./pod-log-tail.js" import * as Shell from "./shell-facade.js" import * as TestRunner from "./test-app-client/test-runner.js" +import { topologicalSort, reverseTopologicalSort } from "./dependency-resolver.js" export const generatePrefix = (env: string): Prefix => { return generatePrefixByDate(new Date(), env) @@ -36,15 +37,24 @@ export const generatePrefixByDate = (date: Date, env: string): Prefix => { /** * Deploying: - * 1. all components in the graph, + * 1. all components in the graph in the topological order * 2. test app for the graph. */ const deployGraph = async (config: Config, workspace: string, testSuiteId: string, graph: Schema.Graph, namespace: Namespace, testAppDirForRemoteTestSuite?: string): Promise => { + // Dependencies are already validated in main(), so it's safe to directly sort here. + const { sortedComponents } = topologicalSort(graph.components) + + logger.info("") + logger.info("Dependency Resolution for %s:", testSuiteId) + logger.info("Deployment order: %s", sortedComponents.map(c => c.id).join(" → ")) + logger.info("") + + // Deploy components in topological order const deployments: Array = new Array() - for (let i = 0; i < graph.components.length; i++) { - const componentSpec = graph.components[i] + for (let i = 0; i < sortedComponents.length; i++) { + const componentSpec = sortedComponents[i] logger.info("") - logger.info("Deploying graph component (%s of %s) \"%s\" for suite \"%s\"...", i + 1, graph.components.length, componentSpec.name, testSuiteId) + logger.info("Deploying graph component (%s of %s) \"%s\" for suite \"%s\"...", i + 1, sortedComponents.length, componentSpec.name, testSuiteId) logger.info("") const commitSha = await Deployer.deployComponent(config, workspace, componentSpec, namespace) deployments.push(new DeployedComponent(commitSha, componentSpec)) @@ -217,8 +227,20 @@ export const undeployAll = async (config: Config, pitfile: Schema.PitFile, suite } await Deployer.undeployComponent(item.workspace, item.namespace, item.graphDeployment.testApp) - for (let deploymentInfo of item.graphDeployment.components) { - await Deployer.undeployComponent(item.workspace, item.namespace, deploymentInfo) + + // Undeploy components in reverse topological order + const componentSpecs = item.graphDeployment.components.map(dep => dep.component) + const reverseSortedComponents = reverseTopologicalSort(topologicalSort(componentSpecs)) + + logger.info("") + logger.info("Undeployment order: %s", reverseSortedComponents.map(c => c.id).join(" → ")) + logger.info("") + + for (let componentSpec of reverseSortedComponents) { + const deployedComponent = item.graphDeployment.components.find(dep => dep.component.id === componentSpec.id) + if (deployedComponent) { + await Deployer.undeployComponent(item.workspace, item.namespace, deployedComponent) + } } await K8s.deleteNamespace(config.parentNamespace, item.namespace, config.namespaceTimeoutSeconds, item.workspace) diff --git a/k8s-deployer/test/component-dependency.spec.ts b/k8s-deployer/test/component-dependency.spec.ts new file mode 100644 index 0000000..2768417 --- /dev/null +++ b/k8s-deployer/test/component-dependency.spec.ts @@ -0,0 +1,125 @@ +import { describe, it } from "mocha" +import { expect } from "chai" +import * as path from "path" +import { fileURLToPath } from "url" +import * as PifFileLoader from "../src/pitfile/pitfile-loader.js" +import { validateDependencies, topologicalSort, reverseTopologicalSort } from "../src/dependency-resolver.js" +import { DependencyValidationError, CyclicDependencyError } from "../src/errors.js" +import { Schema } from "../src/model.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +describe("Component Dependency Tests", () => { + describe("Valid Dependencies", () => { + it("should validate and sort components with valid dependencies", async () => { + const pitfilePath = path.join(__dirname, "pitfile", "test-pitfile-valid-with-dependencies.yml") + const pitfile = await PifFileLoader.loadFromFile(pitfilePath) + const testSuite = pitfile.testSuites[0] + const components = testSuite.deployment.graph.components + + // Should not throw validation error + expect(() => validateDependencies(components, testSuite.name)).not.to.throw() + + // Test topological sorting + const sortResult = topologicalSort(components) + const sortedIds = sortResult.sortedComponents.map(c => c.id) + + // Expected order: database -> api-service, cache -> frontend + expect(sortedIds).to.deep.equal(["database", "api-service", "cache", "frontend"]) + + // Test levels + expect(sortResult.levels).to.have.length(3) + expect(sortResult.levels[0].map(c => c.id)).to.deep.equal(["database"]) + expect(sortResult.levels[1].map(c => c.id)).to.deep.equal(["api-service", "cache"]) + expect(sortResult.levels[2].map(c => c.id)).to.deep.equal(["frontend"]) + + // Test reverse sorting for undeployment + const reverseSorted = reverseTopologicalSort(sortResult) + const reverseIds = reverseSorted.map(c => c.id) + + // Expected undeployment order: frontend -> api-service, cache -> database + expect(reverseIds).to.deep.equal(["frontend", "api-service", "cache", "database"]) + }) + + it("should handle components without dependsOn field (backward compatibility)", async () => { + const pitfilePath = path.join(__dirname, "pitfile", "test-pitfile-valid-without-dependencies.yml") + const pitfile = await PifFileLoader.loadFromFile(pitfilePath) + const testSuite = pitfile.testSuites[0] + const components = testSuite.deployment.graph.components + + // Should not throw validation error + expect(() => validateDependencies(components, testSuite.name)).not.to.throw() + + // Should maintain original order + const sortResult = topologicalSort(components) + expect(sortResult.sortedComponents.map(c => c.id)).to.deep.equal(["component-a", "component-b", "component-c"]) + }) + }) + + describe("Cyclic Dependencies", () => { + it("should detect and report cyclic dependencies", async () => { + const pitfilePath = path.join(__dirname, "pitfile", "test-pitfile-invalid-with-cyclic-dependencies.yml") + const pitfile = await PifFileLoader.loadFromFile(pitfilePath) + const testSuite = pitfile.testSuites[0] + const components = testSuite.deployment.graph.components + + // Should throw validation error + expect(() => validateDependencies(components, testSuite.name)).to.throw(DependencyValidationError) + + try { + validateDependencies(components, testSuite.name) + } catch (error) { + expect(error).to.be.instanceOf(DependencyValidationError) + expect(error.errors).to.have.length(1) + expect(error.errors[0]).to.be.instanceOf(CyclicDependencyError) + + const cyclicError = error.errors[0] as CyclicDependencyError + expect(cyclicError.cyclePath).to.deep.equal(["component-a", "component-b"]) + } + }) + }) + + describe("Complex Dependency Scenarios", () => { + it("should handle complex dependency graphs correctly", async () => { + const pitfilePath = path.join(__dirname, "pitfile", "test-pitfile-valid-with-complex-dependencies.yml") + const pitfile = await PifFileLoader.loadFromFile(pitfilePath) + const testSuite = pitfile.testSuites[0] + const components = testSuite.deployment.graph.components + + // Should validate successfully + expect(() => validateDependencies(components, testSuite.name)).not.to.throw() + + // Test deployment order + const sortResult = topologicalSort(components) + const deploymentOrder = sortResult.sortedComponents.map(c => c.id) + + // Expected deployment order: + // database, message-queue -> api-service, cache-service, task-worker -> backend for frontend -> frontend + expect(deploymentOrder).to.deep.equal([ + "database", "message-queue", + "api-service", "cache-service", "task-worker", + "backend-for-frontend", + "frontend" + ]) + + // Test undeployment order + const undeploymentOrder = reverseTopologicalSort(sortResult).map(c => c.id) + + // Expected undeployment order: + // frontend -> backend for frontend -> api-service, cache-service, task-worker -> database, message-queue + expect(undeploymentOrder).to.deep.equal([ + "frontend", + "backend-for-frontend", + "api-service", "cache-service", "task-worker", + "database", "message-queue" + ]) + + // Verify levels + expect(sortResult.levels).to.have.length(4) + expect(sortResult.levels[0].map(c => c.id)).to.deep.equal(["database", "message-queue"]) + expect(sortResult.levels[1].map(c => c.id)).to.deep.equal(["api-service", "cache-service", "task-worker"]) + expect(sortResult.levels[2].map(c => c.id)).to.deep.equal(["backend-for-frontend"]) + expect(sortResult.levels[3].map(c => c.id)).to.deep.equal(["frontend"]) + }) + }) +}) diff --git a/k8s-deployer/test/dependency-resolver.spec.ts b/k8s-deployer/test/dependency-resolver.spec.ts new file mode 100644 index 0000000..552789e --- /dev/null +++ b/k8s-deployer/test/dependency-resolver.spec.ts @@ -0,0 +1,363 @@ +import { describe, it } from "mocha" +import { expect } from "chai" +import { + validateDependencies, + detectCyclicDependencies, + topologicalSort, + reverseTopologicalSort +} from "../src/dependency-resolver.js" +import { Schema } from "../src/model.js" +import { + CyclicDependencyError, + DependencyValidationError +} from "../src/errors.js" + +describe("Dependency Resolver", () => { + describe("validateDependencies", () => { + it("should pass validation for components without dependencies", () => { + const components: Array = [ + { + name: "Component A", + id: "component-a", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" } + }, + { + name: "Component B", + id: "component-b", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" } + } + ] + + expect(() => validateDependencies(components, "test-suite")).not.to.throw() + }) + + it("should pass validation for valid dependencies", () => { + const components: Array = [ + { + name: "Database", + id: "database", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: [] + }, + { + name: "API Service", + id: "api-service", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["database"] + } + ] + + expect(() => validateDependencies(components, "test-suite")).not.to.throw() + }) + + it("should throw error for duplicate component IDs", () => { + const components: Array = [ + { + name: "Component A", + id: "duplicate-id", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" } + }, + { + name: "Component B", + id: "duplicate-id", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" } + } + ] + + expect(() => validateDependencies(components, "test-suite")).to.throw(DependencyValidationError) + }) + + it("should throw error for self-dependency", () => { + const components: Array = [ + { + name: "Component A", + id: "component-a", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["component-a"] + } + ] + + expect(() => validateDependencies(components, "test-suite")).to.throw(DependencyValidationError) + }) + + it("should throw error for invalid dependency reference", () => { + const components: Array = [ + { + name: "Component A", + id: "component-a", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["non-existent-component"] + } + ] + + expect(() => validateDependencies(components, "test-suite")).to.throw(DependencyValidationError) + }) + + it("should throw error for cyclic dependencies", () => { + const components: Array = [ + { + name: "Component A", + id: "component-a", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["component-b"] + }, + { + name: "Component B", + id: "component-b", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["component-a"] + } + ] + + expect(() => validateDependencies(components, "test-suite")).to.throw(DependencyValidationError) + }) + }) + + describe("detectCyclicDependencies", () => { + it("should return undefined for no cycles", () => { + const components: Array = [ + { + name: "Database", + id: "database", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: [] + }, + { + name: "API Service", + id: "api-service", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["database"] + } + ] + + expect(detectCyclicDependencies(components)).to.be.undefined + }) + + it("should detect simple cycle", () => { + const components: Array = [ + { + name: "Component A", + id: "component-a", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["component-b"] + }, + { + name: "Component B", + id: "component-b", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["component-a"] + } + ] + + const result = detectCyclicDependencies(components) + expect(result).to.be.instanceOf(CyclicDependencyError) + expect(result.cyclePath).to.deep.equal(["component-a", "component-b"]) + }) + + it("should detect complex cycle", () => { + const components: Array = [ + { + name: "Component A", + id: "component-a", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["component-b"] + }, + { + name: "Component B", + id: "component-b", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["component-c"] + }, + { + name: "Component C", + id: "component-c", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["component-a"] + } + ] + + const result = detectCyclicDependencies(components) + expect(result).to.be.instanceOf(CyclicDependencyError) + expect(result.cyclePath).to.have.length(3) + expect(result.cyclePath).to.include("component-a") + expect(result.cyclePath).to.include("component-b") + expect(result.cyclePath).to.include("component-c") + }) + }) + + describe("topologicalSort", () => { + it("should sort components without dependencies in original order", () => { + const components: Array = [ + { + name: "Component A", + id: "component-a", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" } + }, + { + name: "Component B", + id: "component-b", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" } + }, + { + name: "Component C", + id: "component-c", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" } + } + ] + + const result = topologicalSort(components) + expect(result.sortedComponents.map(c => c.id)).to.deep.equal(["component-a", "component-b", "component-c"]) + }) + + it("should sort components with dependencies correctly", () => { + const components: Array = [ + { + name: "API Service", + id: "api-service", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["database"] + }, + { + name: "Frontend", + id: "frontend", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["api-service"] + }, + { + name: "Database", + id: "database", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: [] + } + ] + + const result = topologicalSort(components) + expect(result.sortedComponents.map(c => c.id)).to.deep.equal(["database", "api-service", "frontend"]) + }) + + it("should maintain original order for same-level components", () => { + const components: Array = [ + { + name: "Database", + id: "database", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: [] + }, + { + name: "API Service", + id: "api-service", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["database"] + }, + { + name: "Cache", + id: "cache", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["database"] + } + ] + + const result = topologicalSort(components) + expect(result.sortedComponents.map(c => c.id)).to.deep.equal(["database", "api-service", "cache"]) + expect(result.levels).to.have.length(2) + expect(result.levels[0].map(c => c.id)).to.deep.equal(["database"]) + expect(result.levels[1].map(c => c.id)).to.deep.equal(["api-service", "cache"]) + }) + }) + + describe("reverseTopologicalSort", () => { + it("should reverse dependency levels but maintain order within levels", () => { + const components: Array = [ + { + name: "Database", + id: "database", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: [] + }, + { + name: "API Service", + id: "api-service", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["database"] + }, + { + name: "Frontend", + id: "frontend", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["api-service"] + }, + { + name: "Cache", + id: "cache", + location: { type: Schema.LocationType.Local }, + deploy: { command: "deploy.sh" }, + undeploy: { command: "undeploy.sh" }, + dependsOn: ["database"] + } + ] + + const reverseResult = reverseTopologicalSort(topologicalSort(components)) + + // Reverse levels only, but same order within level: frontend, api-service, cache, database + expect(reverseResult.map(c => c.id)).to.deep.equal(["frontend", "api-service", "cache", "database"]) + }) + }) +}) diff --git a/k8s-deployer/test/errors.spec.ts b/k8s-deployer/test/errors.spec.ts new file mode 100644 index 0000000..3c2be70 --- /dev/null +++ b/k8s-deployer/test/errors.spec.ts @@ -0,0 +1,71 @@ +import { describe, it } from "mocha" +import { expect } from "chai" +import { + CyclicDependencyError, + InvalidDependencyError, + SelfDependencyError, + DuplicateComponentIdError, + DependencyValidationError +} from "../src/errors.js" + +describe("Error Classes", () => { + describe("CyclicDependencyError", () => { + it("should create error with cycle path", () => { + const cyclePath = ["component-a", "component-b", "component-c"] + const error = new CyclicDependencyError(cyclePath, "component-a") + + expect(error.name).to.equal("CyclicDependencyError") + expect(error.message).to.equal("Cyclic dependency detected: component-a → component-b → component-c → component-a") + expect(error.cyclePath).to.deep.equal(cyclePath) + expect(error.componentId).to.equal("component-a") + }) + }) + + describe("InvalidDependencyError", () => { + it("should create error with component and invalid dependency", () => { + const error = new InvalidDependencyError("component-a", "non-existent") + + expect(error.name).to.equal("InvalidDependencyError") + expect(error.message).to.equal("Component component-a references non-existent component non-existent") + expect(error.componentId).to.equal("component-a") + expect(error.invalidDependency).to.equal("non-existent") + }) + }) + + describe("SelfDependencyError", () => { + it("should create error with component ID", () => { + const error = new SelfDependencyError("component-a") + + expect(error.name).to.equal("SelfDependencyError") + expect(error.message).to.equal("Component component-a cannot depend on itself") + expect(error.componentId).to.equal("component-a") + }) + }) + + describe("DuplicateComponentIdError", () => { + it("should create error with duplicate component ID", () => { + const error = new DuplicateComponentIdError("duplicate-id") + + expect(error.name).to.equal("DuplicateComponentIdError") + expect(error.message).to.equal("Duplicate component ID duplicate-id found") + expect(error.componentId).to.equal("duplicate-id") + }) + }) + + describe("DependencyValidationError", () => { + it("should create error with test suite name and errors", () => { + const errors = [ + new InvalidDependencyError("component-a", "non-existent"), + new SelfDependencyError("component-b") + ] + const error = new DependencyValidationError("test-suite", errors) + + expect(error.name).to.equal("DependencyValidationError") + expect(error.message).to.include("Dependency validation failed for test suite test-suite") + expect(error.message).to.include("Component component-a references non-existent component non-existent") + expect(error.message).to.include("Component component-b cannot depend on itself") + expect(error.testSuiteName).to.equal("test-suite") + expect(error.errors).to.deep.equal(errors) + }) + }) +}) diff --git a/k8s-deployer/test/pitfile/pitfile-loader.spec.ts b/k8s-deployer/test/pitfile/pitfile-loader.spec.ts index 2cf7272..13402d3 100644 --- a/k8s-deployer/test/pitfile/pitfile-loader.spec.ts +++ b/k8s-deployer/test/pitfile/pitfile-loader.spec.ts @@ -1,5 +1,5 @@ import * as chai from "chai" -import chaiAsPromised from 'chai-as-promised' +import chaiAsPromised from "chai-as-promised" import * as sinon from "sinon" chai.use(chaiAsPromised) @@ -51,6 +51,54 @@ describe("Loads pitfile from disk", () => { await chai.expect(PifFileLoader.loadFromFile("dist/test/pitfile/test-pitfile-valid-3-invalid.yml")).eventually.rejectedWith(errorMessage) }) + it("should load pitfile with dependsOn field", async () => { + const file = await PifFileLoader.loadFromFile("dist/test/pitfile/test-pitfile-valid-with-dependencies.yml") + chai.expect(file.projectName).eq("Dependency Test Example") + chai.expect(file.testSuites).lengthOf(1) + + const testSuite = file.testSuites[0] + chai.expect(testSuite.name).eq("Dependency Test Suite") + chai.expect(testSuite.deployment.graph.components).lengthOf(4) + + const components = testSuite.deployment.graph.components + const database = components.find(c => c.id === "database") + const apiService = components.find(c => c.id === "api-service") + const cache = components.find(c => c.id === "cache") + const frontend = components.find(c => c.id === "frontend") + + chai.expect(database).not.undefined + chai.expect(database!.dependsOn).undefined + + chai.expect(apiService).not.undefined + chai.expect(apiService!.dependsOn).deep.equal(["database"]) + + chai.expect(cache).not.undefined + chai.expect(cache!.dependsOn).deep.equal(["database"]) + + chai.expect(frontend).not.undefined + chai.expect(frontend!.dependsOn).deep.equal(["api-service", "cache"]) + }) + + it("should load pitfile with cyclic dependencies", async () => { + const file = await PifFileLoader.loadFromFile("dist/test/pitfile/test-pitfile-invalid-with-cyclic-dependencies.yml") + chai.expect(file.projectName).eq("Cyclic Dependency Test Example") + chai.expect(file.testSuites).lengthOf(1) + + const testSuite = file.testSuites[0] + chai.expect(testSuite.name).eq("Cyclic Dependency Test Suite") + chai.expect(testSuite.deployment.graph.components).lengthOf(2) + + const components = testSuite.deployment.graph.components + const componentA = components.find(c => c.id === "component-a") + const componentB = components.find(c => c.id === "component-b") + + chai.expect(componentA).not.undefined + chai.expect(componentA!.dependsOn).deep.equal(["component-b"]) + + chai.expect(componentB).not.undefined + chai.expect(componentB!.dependsOn).deep.equal(["component-a"]) + }) + after(() => { sandbox.restore() }) diff --git a/k8s-deployer/test/pitfile/test-pitfile-invalid-with-cyclic-dependencies.yml b/k8s-deployer/test/pitfile/test-pitfile-invalid-with-cyclic-dependencies.yml new file mode 100644 index 0000000..d132018 --- /dev/null +++ b/k8s-deployer/test/pitfile/test-pitfile-invalid-with-cyclic-dependencies.yml @@ -0,0 +1,40 @@ +version: 1.0 +projectName: Cyclic Dependency Test Example + +lockManager: + enabled: false + +testSuites: + - name: Cyclic Dependency Test Suite + id: cyclic-test + + deployment: + graph: + testApp: + name: Test App + id: test-app + location: + type: LOCAL + deploy: + command: "echo 'test app deployed'" + undeploy: + command: "echo 'test app undeployed'" + + components: + - name: Component A + id: component-a + dependsOn: + - component-b + deploy: + command: "echo 'component-a deployed'" + undeploy: + command: "echo 'component-a undeployed'" + + - name: Component B + id: component-b + dependsOn: + - component-a + deploy: + command: "echo 'component-b deployed'" + undeploy: + command: "echo 'component-b undeployed'" diff --git a/k8s-deployer/test/pitfile/test-pitfile-valid-with-complex-dependencies.yml b/k8s-deployer/test/pitfile/test-pitfile-valid-with-complex-dependencies.yml new file mode 100644 index 0000000..b4d2615 --- /dev/null +++ b/k8s-deployer/test/pitfile/test-pitfile-valid-with-complex-dependencies.yml @@ -0,0 +1,83 @@ +version: 1.0 +projectName: Complex Dependency Test Example + +lockManager: + enabled: false + +testSuites: + - name: Complex Dependency Test Suite + id: complex-dependency-test + + deployment: + graph: + testApp: + name: Test App + id: test-app + location: + type: LOCAL + deploy: + command: "echo 'test app deployed'" + undeploy: + command: "echo 'test app undeployed'" + + components: + - name: Database + id: database + deploy: + command: "echo 'database deployed'" + undeploy: + command: "echo 'database undeployed'" + + - name: Message Queue + id: message-queue + deploy: + command: "echo 'message queue deployed'" + undeploy: + command: "echo 'message queue undeployed'" + + - name: API Service + id: api-service + dependsOn: + - database + - message-queue + deploy: + command: "echo 'api-service deployed'" + undeploy: + command: "echo 'api-service undeployed'" + + - name: Cache Service + id: cache-service + dependsOn: + - database + deploy: + command: "echo 'cache service deployed'" + undeploy: + command: "echo 'cache service undeployed'" + + - name: Backend For Frontend + id: backend-for-frontend + dependsOn: + - api-service + - cache-service + deploy: + command: "echo 'backend for frontend deployed'" + undeploy: + command: "echo 'backend for frontend undeployed'" + + - name: Frontend + id: frontend + dependsOn: + - backend-for-frontend + deploy: + command: "echo 'frontend deployed'" + undeploy: + command: "echo 'frontend undeployed'" + + - name: Task Worker + id: task-worker + dependsOn: + - message-queue + deploy: + command: "echo 'task worker deployed'" + undeploy: + command: "echo 'task worker undeployed'" \ No newline at end of file diff --git a/k8s-deployer/test/pitfile/test-pitfile-valid-with-dependencies.yml b/k8s-deployer/test/pitfile/test-pitfile-valid-with-dependencies.yml new file mode 100644 index 0000000..d0c58eb --- /dev/null +++ b/k8s-deployer/test/pitfile/test-pitfile-valid-with-dependencies.yml @@ -0,0 +1,57 @@ +version: 1.0 +projectName: Dependency Test Example + +lockManager: + enabled: false + +testSuites: + - name: Dependency Test Suite + id: dependency-test + + deployment: + graph: + testApp: + name: Test App + id: test-app + location: + type: LOCAL + deploy: + command: "echo 'test app deployed'" + undeploy: + command: "echo 'test app undeployed'" + + components: + - name: Database + id: database + deploy: + command: "echo 'database deployed'" + undeploy: + command: "echo 'database undeployed'" + + - name: API Service + id: api-service + dependsOn: + - database + deploy: + command: "echo 'api-service deployed'" + undeploy: + command: "echo 'api-service undeployed'" + + - name: Cache + id: cache + dependsOn: + - database + deploy: + command: "echo 'cache deployed'" + undeploy: + command: "echo 'cache undeployed'" + + - name: Frontend + id: frontend + dependsOn: + - api-service + - cache + deploy: + command: "echo 'frontend deployed'" + undeploy: + command: "echo 'frontend undeployed'" diff --git a/k8s-deployer/test/pitfile/test-pitfile-valid-without-dependencies.yml b/k8s-deployer/test/pitfile/test-pitfile-valid-without-dependencies.yml new file mode 100644 index 0000000..51661d5 --- /dev/null +++ b/k8s-deployer/test/pitfile/test-pitfile-valid-without-dependencies.yml @@ -0,0 +1,43 @@ +version: 1.0 +projectName: Backward Compatibility Test Example (no dependsOn field) + +lockManager: + enabled: false + +testSuites: + - name: Backward Compatibility Test Suite (no dependsOn field) + id: backward-compatibility-test + + deployment: + graph: + testApp: + name: Test App + id: test-app + location: + type: LOCAL + deploy: + command: "echo 'test app deployed'" + undeploy: + command: "echo 'test app undeployed'" + + components: + - name: Component A + id: component-a + deploy: + command: "echo 'component-a deployed'" + undeploy: + command: "echo 'component-a undeployed'" + + - name: Component B + id: component-b + deploy: + command: "echo 'component-b deployed'" + undeploy: + command: "echo 'component-b undeployed'" + + - name: Component C + id: component-c + deploy: + command: "echo 'component-c deployed'" + undeploy: + command: "echo 'component-c undeployed'"