diff --git a/src/argv.ts b/src/argv.ts index e697dac21..4b182c939 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -266,6 +266,10 @@ export class Argv { return this.map.get("preview") ?? false; } + get validateDependencyChain (): boolean { + return this.map.get("validateDependencyChain") ?? false; + } + get shellIsolation (): boolean { // TODO: default to true in 5.x.x return this.map.get("shellIsolation") ?? false; diff --git a/src/commander.ts b/src/commander.ts index 006417aea..ac49c54bf 100644 --- a/src/commander.ts +++ b/src/commander.ts @@ -272,4 +272,24 @@ export class Commander { }); } + static validateDependencyChain (parser: Parser) { + const allJobs = parser.jobs; + // This is only the jobs that will actually run + const activeJobs = allJobs.filter(j => j.when !== "never"); + const stages = parser.stages; + // This will throw an assertion errror if the dependency chain is broken due to needs keyword on specific events without having to run the full pipeline + Executor.getStartCandidates(allJobs, stages, activeJobs, []); + + const activeJobNames = new Set(activeJobs.map(job => job.name)); + // This willl throw an assertion error if the dependency chain is broken due to dependencies keyword (a job depending on artifacts from a job that will never run) without having to run the full pipeline + for (const job of activeJobs) { + if (job.dependencies) { + for (const dependency of job.dependencies) { + if (!activeJobNames.has(dependency)) { + throw new AssertionError({message: chalk`{blueBright ${dependency}} is when:never, but its depended on by {blueBright ${job.name}}`}); + } + } + } + } + } } diff --git a/src/handler.ts b/src/handler.ts index bdbbe530a..0b9621a29 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -54,6 +54,11 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[ const pipelineIid = await state.getPipelineIid(cwd, stateDir); parser = await Parser.create(argv, writeStreams, pipelineIid, jobs); Commander.runList(parser, writeStreams, argv.listAll); + } else if (argv.validateDependencyChain) { + const pipelineIid = await state.getPipelineIid(cwd, stateDir); + parser = await Parser.create(argv, writeStreams, pipelineIid, jobs); + Commander.validateDependencyChain(parser); + writeStreams.stdout(chalk`{green ✓ All job dependencies are valid}\n`); } else if (argv.listJson) { const pipelineIid = await state.getPipelineIid(cwd, stateDir); parser = await Parser.create(argv, writeStreams, pipelineIid, jobs); diff --git a/src/index.ts b/src/index.ts index cb8ef1af5..88d99f635 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,6 +95,11 @@ process.on("SIGUSR2", async () => await cleanupJobResources(jobs)); description: "List job information in csv format, when:never included", requiresArg: false, }) + .option("validate-dependency-chain", { + type: "boolean", + description: "Validate that jobs needed or dependent by active jobs under specified conditions are also active without actually running the jobs. Uses fail-fast approach - stops at first validation error for both 'needs' and 'dependencies' keywords. If validation fails, use --list flag to see which jobs will run under specified conditions", + requiresArg: false, + }) .option("preview", { type: "boolean", description: "Print YML with defaults, includes, extends and reference's expanded", diff --git a/tests/test-cases/validate-dependency-chain/.gitlab-ci.yml b/tests/test-cases/validate-dependency-chain/.gitlab-ci.yml new file mode 100644 index 000000000..22e9857c2 --- /dev/null +++ b/tests/test-cases/validate-dependency-chain/.gitlab-ci.yml @@ -0,0 +1,81 @@ +--- +stages: + - build + - test + +alpine-guest: + image: nginxinc/nginx-unprivileged:alpine3.18 + needs: ["alpine-root"] + script: + - stat -c "%a %n %u %g" one.txt + - stat -c "%a %n %u %g" script.sh + rules: + - if: '$RUN_ALL == "true"' + - if: '$RUN_SINGLE == "alpine-root"' + - if: '$RUN_SINGLE == "alpine-guest"' + +alpine-root: + needs: ["kaniko-root"] + image: nginx:alpine3.18 + rules: + - if: '$RUN_ALL == "true"' + - if: '$RUN_SINGLE == "alpine-root"' + - if: '$RUN_SINGLE == "alpine-guest"' + when: never + script: + - stat -c "%a %n %u %g" one.txt + - stat -c "%a %n %u %g" script.sh + +kaniko-root: + image: + name: gcr.io/kaniko-project/executor:v1.23.0-debug + entrypoint: [""] + rules: + - if: '$RUN_ALL == "true"' + script: + - stat -c "%a %n %u %g" one.txt + - stat -c "%a %n %u %g" script.sh + + +kaniko-guest: + image: + name: gcr.io/kaniko-project/executor:v1.23.0-debug + entrypoint: [""] + rules: + - if: '$RUN_ALL == "true"' + script: + - stat -c "%a %n %u %g" one.txt + - stat -c "%a %n %u %g" script.sh + + +build-job-1: + stage: build + script: echo "build 1" + artifacts: + paths: [build1.txt] + rules: + - if: '$RUN_ALL == "true"' + - if: '$TEST_DEPENDENCIES == "true"' + + +test-job: + stage: test + dependencies: [build-job-1] + rules: + - if: '$RUN_ALL == "true"' + script: echo "testing" + +build-job-2: + stage: build + script: echo "build 2" + artifacts: + paths: [build2.txt] + rules: + - if: '$RUN_ALL == "true"' + +broken-dependencies-job: + stage: test + dependencies: [build-job-1, build-job-2] + rules: + - if: '$TEST_DEPENDENCIES == "true"' + script: echo "This has broken dependencies" diff --git a/tests/test-cases/validate-dependency-chain/integration.test.ts b/tests/test-cases/validate-dependency-chain/integration.test.ts new file mode 100644 index 000000000..f25a1e47b --- /dev/null +++ b/tests/test-cases/validate-dependency-chain/integration.test.ts @@ -0,0 +1,59 @@ +import {WriteStreamsMock} from "../../../src/write-streams.js"; +import {handler} from "../../../src/handler.js"; +import chalk from "chalk"; +import {initSpawnSpy} from "../../mocks/utils.mock.js"; +import {WhenStatics} from "../../mocks/when-statics.js"; + +beforeAll(() => { + initSpawnSpy(WhenStatics.all); +}); + +describe("validate-dependency-chain", () => { + test("should pass when all dependencies are valid", async () => { + const writeStreams = new WriteStreamsMock(); + await handler({ + cwd: "tests/test-cases/validate-dependency-chain", + validateDependencyChain: true, + variable: ["RUN_ALL=true"], + }, writeStreams); + + const output = writeStreams.stdoutLines.join("\n"); + expect(output).toContain(chalk`{green ✓ All job dependencies are valid}`); + + // Check that there are no validation errors in stderr (only info messages) + const validationErrors = writeStreams.stderrLines.filter(line => + line.includes("Dependency chain validation will fail with event"), + ); + expect(validationErrors.length).toBe(0); + }); + + test("should fail when dependency chain is broken due to a non-existent job", async () => { + const writeStreams = new WriteStreamsMock(); + + await expect(handler({ + cwd: "tests/test-cases/validate-dependency-chain", + validateDependencyChain: true, + variable: ["RUN_SINGLE=alpine-root"], + }, writeStreams)).rejects.toThrow(chalk`{blueBright kaniko-root} is when:never, but its needed by {blueBright alpine-root}`); + }); + + test("should fail when dependency chain is broken due to a job that never runs", async () => { + const writeStreams = new WriteStreamsMock(); + + await expect(handler({ + cwd: "tests/test-cases/validate-dependency-chain", + validateDependencyChain: true, + variable: ["RUN_SINGLE=alpine-guest"], + }, writeStreams)).rejects.toThrow(chalk`{blueBright alpine-root} is when:never, but its needed by {blueBright alpine-guest}`); + }); + + test("should fail when dependencies keyword references missing artifact jobs", async () => { + const writeStreams = new WriteStreamsMock(); + + await expect(handler({ + cwd: "tests/test-cases/validate-dependency-chain", + validateDependencyChain: true, + variable: ["TEST_DEPENDENCIES=true"], + }, writeStreams)).rejects.toThrow(chalk`{blueBright build-job-2} is when:never, but its depended on by {blueBright broken-dependencies-job}`); + }); +}); \ No newline at end of file