Skip to content
Merged
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
4 changes: 4 additions & 0 deletions src/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions src/commander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}`});
}
}
}
}
}
}
5 changes: 5 additions & 0 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions tests/test-cases/validate-dependency-chain/.gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -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"
59 changes: 59 additions & 0 deletions tests/test-cases/validate-dependency-chain/integration.test.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
});