mirror of
https://github.com/firecow/gitlab-ci-local.git
synced 2026-06-01 18:37:36 +02:00
fix: prevent ENAMETOOLONG crash for long parallel:matrix job names (#1865)
build / yamllint (push) Has been cancelled
build / test (push) Has been cancelled
build / CodeQL (push) Has been cancelled
build / smoke-test (1.2.23, 24) (push) Has been cancelled
build / smoke-test (1.2.23, 25) (push) Has been cancelled
build / smoke-test (1.3.10, 24) (push) Has been cancelled
build / smoke-test (1.3.10, 25) (push) Has been cancelled
build / typecheck (push) Has been cancelled
build / eslint (push) Has been cancelled
build / unused-deps (push) Has been cancelled
build / yamllint (push) Has been cancelled
build / test (push) Has been cancelled
build / CodeQL (push) Has been cancelled
build / smoke-test (1.2.23, 24) (push) Has been cancelled
build / smoke-test (1.2.23, 25) (push) Has been cancelled
build / smoke-test (1.3.10, 24) (push) Has been cancelled
build / smoke-test (1.3.10, 25) (push) Has been cancelled
build / typecheck (push) Has been cancelled
build / eslint (push) Has been cancelled
build / unused-deps (push) Has been cancelled
Co-authored-by: Gyan Ranjan A <gyan.a.ranjan@ericsson.com> Co-authored-by: Mads Jon Nielsen <madsjon@gmail.com>
This commit is contained in:
+14
-1
@@ -8,6 +8,7 @@ import checksum from "checksum";
|
||||
import base64url from "base64url";
|
||||
import execa, {ExecaError} from "execa";
|
||||
import assert from "node:assert";
|
||||
import {createHash} from "node:crypto";
|
||||
import {CICDVariable} from "./variables-from-files.js";
|
||||
import {GitData} from "./git-data.js";
|
||||
import {globbySync} from "globby";
|
||||
@@ -49,10 +50,22 @@ export class Utils {
|
||||
return url.replace(/^https:\/\//g, "").replace(/^http:\/\//g, "");
|
||||
}
|
||||
|
||||
// gcl-${safeJobName}-${jobId}-build → wrapper is 17 chars (jobId max 6 digits)
|
||||
static readonly MAX_FILENAME_LENGTH = 255 - 17; // NAME_MAX (bytes) - wrapper
|
||||
|
||||
static safeDockerString (jobName: string) {
|
||||
return jobName.replace(/[^\w-]+/g, (match) => {
|
||||
// INVARIANT: \w without /u is ASCII-only ([A-Za-z0-9_]), so `encoded` is pure ASCII
|
||||
// and .length === byte length. NAME_MAX is a byte limit — adding /u would break this.
|
||||
// We hash `jobName` (not `encoded`) because base64url encoding isn't injective.
|
||||
const encoded = jobName.replace(/[^\w-]+/g, (match) => {
|
||||
return base64url.encode(match);
|
||||
});
|
||||
if (encoded.length <= Utils.MAX_FILENAME_LENGTH) {
|
||||
return encoded;
|
||||
}
|
||||
const hash = createHash("sha256").update(jobName).digest("hex").substring(0, 16);
|
||||
const prefix = encoded.substring(0, Utils.MAX_FILENAME_LENGTH - 1 - hash.length);
|
||||
return `${prefix}-${hash}`;
|
||||
}
|
||||
|
||||
static safeBashString (s: string) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.gitlab-ci-local-*
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
build-job:
|
||||
stage: build
|
||||
script:
|
||||
- echo "APP='${APP}' $CI_NODE_INDEX/$CI_NODE_TOTAL"
|
||||
parallel:
|
||||
matrix:
|
||||
- APP:
|
||||
- "my-app-controller,My app controller to be used as reference for development teams,python311,\
|
||||
controller,common,controller/setup.py,controller/setup_c.py,controller/setup_n.py,controller/tests/**/*,\
|
||||
controller/coverage/*,controller/build/**/*,controller/coverage/coverage-unit.xml,75,true"
|
||||
- short
|
||||
@@ -0,0 +1,17 @@
|
||||
import {WriteStreamsMock} from "../../../src/write-streams.js";
|
||||
import {handler} from "../../../src/handler.js";
|
||||
|
||||
test.concurrent("parallel-matrix-long-name - completes without ENAMETOOLONG", async () => {
|
||||
const writeStreams = new WriteStreamsMock();
|
||||
await handler({
|
||||
cwd: "tests/test-cases/parallel-matrix-long-name",
|
||||
shellIsolation: true,
|
||||
stateDir: ".gitlab-ci-local-parallel-matrix-long-name",
|
||||
}, writeStreams);
|
||||
|
||||
// Both matrix entries must complete — the long one would crash with ENAMETOOLONG before the fix
|
||||
const passing = writeStreams.stdoutLines.filter(l => l.includes(" PASS "));
|
||||
expect(passing.length).toBe(2);
|
||||
expect(writeStreams.stdoutLines.some(l => l.includes("build-job: [short]"))).toBe(true);
|
||||
expect(writeStreams.stdoutLines.some(l => l.includes("build-job: [my-app-controller,"))).toBe(true);
|
||||
});
|
||||
@@ -266,3 +266,66 @@ describe("getServiceAlias", () => {
|
||||
expect(Utils.getServiceAlias({...base, name: "library/nginx", alias: "my-nginx"})).toBe("my-nginx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeDockerString", () => {
|
||||
it("should return encoded name unchanged when within limit", () => {
|
||||
const result = Utils.safeDockerString("short-job-name");
|
||||
expect(result).toBe("short-job-name");
|
||||
});
|
||||
|
||||
it("should encode non-alphanumeric characters", () => {
|
||||
const result = Utils.safeDockerString("job/name");
|
||||
expect(result).toBe("jobLwname"); // '/' → 'Lw'
|
||||
});
|
||||
|
||||
it("should truncate and hash when encoded name exceeds MAX_FILENAME_LENGTH", () => {
|
||||
const longName = "my-group/common/python-unit-test: [my-app-controller,My app controller to be used as reference for development teams,python311,controller,common,controller/setup.py,controller/setup_c.py,controller/setup_n.py,controller/tests/**/*,controller/coverage/*,controller/build/**/*,controller/coverage/coverage-unit.xml,75,true]";
|
||||
const result = Utils.safeDockerString(longName);
|
||||
expect(result.length).toBeLessThanOrEqual(Utils.MAX_FILENAME_LENGTH);
|
||||
});
|
||||
|
||||
it("should produce deterministic output for the same input", () => {
|
||||
const longName = "a".repeat(50) + "/" + "b".repeat(200);
|
||||
const result1 = Utils.safeDockerString(longName);
|
||||
const result2 = Utils.safeDockerString(longName);
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
|
||||
it("should produce different output for different long inputs", () => {
|
||||
const name1 = "job: [" + "a".repeat(300) + "]";
|
||||
const name2 = "job: [" + "b".repeat(300) + "]";
|
||||
const result1 = Utils.safeDockerString(name1);
|
||||
const result2 = Utils.safeDockerString(name2);
|
||||
expect(result1).not.toBe(result2);
|
||||
});
|
||||
|
||||
it("should handle extremely long job names (1000+ chars)", () => {
|
||||
const extremeName = "group/subgroup/job: [" + "x".repeat(2000) + "]";
|
||||
const result = Utils.safeDockerString(extremeName);
|
||||
expect(result.length).toBeLessThanOrEqual(Utils.MAX_FILENAME_LENGTH);
|
||||
expect(result.length).toBeGreaterThan(16); // has prefix + hash
|
||||
});
|
||||
|
||||
it("should keep volume name within NAME_MAX=255 (worst-case suffix)", () => {
|
||||
const longName = "my-group/common/python-unit-test: [" + "a/b/c,".repeat(100) + "]";
|
||||
const safeJobName = Utils.safeDockerString(longName);
|
||||
const worstCaseVolume = `gcl-${safeJobName}-999999-build`;
|
||||
expect(worstCaseVolume.length).toBeLessThanOrEqual(255);
|
||||
});
|
||||
|
||||
it("should not hash names that are exactly at the limit", () => {
|
||||
// Create a name whose encoded form is exactly MAX_FILENAME_LENGTH
|
||||
const name = "a".repeat(Utils.MAX_FILENAME_LENGTH);
|
||||
const result = Utils.safeDockerString(name);
|
||||
expect(result).toBe(name); // all alphanumeric, no encoding, no hash
|
||||
});
|
||||
|
||||
it("should hash names whose encoded form is one char over the limit", () => {
|
||||
// 'a' stays as 'a', '/' encodes to 'Lw' (2 chars)
|
||||
// Build a string that encodes to exactly MAX_FILENAME_LENGTH + 1
|
||||
const name = "a".repeat(Utils.MAX_FILENAME_LENGTH - 1) + "/"; // '/' -> 'Lw' = +2, total = MAX+1
|
||||
const result = Utils.safeDockerString(name);
|
||||
expect(result.length).toBeLessThanOrEqual(Utils.MAX_FILENAME_LENGTH);
|
||||
expect(result).toContain("-"); // has hash separator
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user