Skip to content

Commit f366a26

Browse files
authored
feat: implement cache:key:prefix and some enhancement to make gcl cache work more similar to gitlab.com (#1556)
1 parent 04bd483 commit f366a26

File tree

10 files changed

+320
-48
lines changed

10 files changed

+320
-48
lines changed

src/job.ts

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {handler} from "./handler.js";
1616
import * as yaml from "js-yaml";
1717
import {Parser} from "./parser.js";
1818
import {resolveIncludeLocal, validateIncludeLocal} from "./parser-includes.js";
19+
import globby from "globby";
1920
import terminalLink from "terminal-link";
2021

2122
const GCL_SHELL_PROMPT_PLACEHOLDER = "<gclShellPromptPlaceholder>";
@@ -470,7 +471,22 @@ export class Job {
470471
return this.jobData["cache"] || [];
471472
}
472473

473-
public async getUniqueCacheName (cwd: string, expanded: {[key: string]: string}, key: any) {
474+
public async getUniqueCacheName (cwd: string, expanded: {[key: string]: string}, cacheIndex: number) {
475+
const getCachePrefix = (index: number) => {
476+
const prefix = this.jobData["cache"][index]["key"]["prefix"];
477+
if (prefix) {
478+
return `${index}_${Utils.expandText(prefix, expanded)}-`;
479+
};
480+
481+
const filenames = this.jobData["cache"][index]["key"]["files"].map((p: string) => {
482+
const expandP = Utils.expandText(p, expanded);
483+
return expandP.split(".")[0];
484+
}).join("_");
485+
return `${index}_${filenames}-`;
486+
};
487+
488+
const key = this.jobData["cache"][cacheIndex].key;
489+
474490
if (typeof key === "string" || key == null) {
475491
return Utils.expandText(key ?? "default", expanded);
476492
}
@@ -482,7 +498,7 @@ export class Job {
482498
}
483499
return `${cwd}/${path}`;
484500
});
485-
return "md-" + await Utils.checksumFiles(cwd, files);
501+
return getCachePrefix(cacheIndex) + await Utils.checksumFiles(cwd, files);
486502
}
487503

488504
get beforeScripts (): string[] {
@@ -747,8 +763,8 @@ export class Job {
747763
if (this.imageName(expanded) && !this.argv.mountCache) return [];
748764

749765
const cmd: string[] = [];
750-
for (const c of this.cache) {
751-
const uniqueCacheName = await this.getUniqueCacheName(this.argv.cwd, expanded, c.key);
766+
for (const [index, c] of this.cache.entries()) {
767+
const uniqueCacheName = await this.getUniqueCacheName(this.argv.cwd, expanded, index);
752768
c.paths.forEach((p) => {
753769
const path = Utils.expandText(p, expanded);
754770
writeStreams.stdout(chalk`${this.formattedJobName} {magentaBright mounting cache} for path ${path}\n`);
@@ -1116,11 +1132,11 @@ export class Job {
11161132
const cwd = this.argv.cwd;
11171133
const stateDir = this.argv.stateDir;
11181134

1119-
for (const c of this.cache) {
1135+
for (const [index, c] of this.cache.entries()) {
11201136
if (!["pull", "pull-push"].includes(c.policy)) return;
11211137

11221138
const time = process.hrtime();
1123-
const cacheName = await this.getUniqueCacheName(cwd, expanded, c.key);
1139+
const cacheName = await this.getUniqueCacheName(cwd, expanded, index);
11241140
const cacheFolder = `${cwd}/${stateDir}/cache/${cacheName}`;
11251141
if (!await fs.pathExists(cacheFolder)) {
11261142
continue;
@@ -1177,30 +1193,56 @@ export class Job {
11771193
const cachePath = this.imageName(expanded) ? "/cache" : "../../cache";
11781194

11791195
let time, endTime;
1180-
for (const c of this.cache) {
1196+
for (const [index, c] of this.cache.entries()) {
11811197
if (!["push", "pull-push"].includes(c.policy)) return;
11821198
if ("on_success" === c.when && this.jobStatus !== "success") return;
11831199
if ("on_failure" === c.when && this.jobStatus === "success") return;
1184-
const cacheName = await this.getUniqueCacheName(cwd, expanded, c.key);
1200+
const cacheName = await this.getUniqueCacheName(cwd, expanded, index);
1201+
1202+
let paths = "";
11851203
for (const path of c.paths) {
1186-
time = process.hrtime();
1187-
const expandedPath = Utils.expandText(path, expanded).replace(`${expanded.CI_PROJECT_DIR}/`, "");
1188-
let cmd = "shopt -s globstar nullglob dotglob\n";
1189-
cmd += `mkdir -p ${cachePath}/${cacheName}\n`;
1190-
cmd += `rsync -Ra ${expandedPath} ${cachePath}/${cacheName}/. || true\n`;
1191-
1192-
await Mutex.exclusive(cacheName, async () => {
1193-
await this.copyOut(cmd, stateDir, "cache", []);
1194-
});
1195-
endTime = process.hrtime(time);
1204+
if (!Utils.isSubpath(path, this.argv.cwd, this.argv.cwd)) continue;
11961205

1197-
const readdir = await fs.readdir(`${this.argv.cwd}/${stateDir}/cache/${cacheName}`);
1198-
if (readdir.length === 0) {
1199-
writeStreams.stdout(chalk`${this.formattedJobName} {yellow !! no cache was copied for ${path} !!}\n`);
1200-
} else {
1201-
writeStreams.stdout(chalk`${this.formattedJobName} {magentaBright exported cache ${expandedPath} '${cacheName}'} in {magenta ${prettyHrtime(endTime)}}\n`);
1206+
paths += " " + Utils.expandText(path, expanded).replace(`${expanded.CI_PROJECT_DIR}/`, "");
1207+
}
1208+
1209+
time = process.hrtime();
1210+
let cmd = "shopt -s globstar nullglob dotglob\n";
1211+
cmd += `mkdir -p ${cachePath}/${cacheName}\n`;
1212+
cmd += `rsync -Ra ${paths} ${cachePath}/${cacheName}/. || true\n`;
1213+
1214+
await Mutex.exclusive(cacheName, async () => {
1215+
await this.copyOut(cmd, stateDir, "cache", []);
1216+
});
1217+
endTime = process.hrtime(time);
1218+
1219+
for (const _path of c.paths) {
1220+
if (!Utils.isSubpath(_path, this.argv.cwd, this.argv.cwd)) {
1221+
writeStreams.stdout(chalk`{yellow WARNING: processPath: artifact path is not a subpath of project directory: ${_path}}\n`);
1222+
continue;
1223+
}
1224+
1225+
let path = _path;
1226+
if (globby.hasMagic(path) && !path.endsWith("*")) {
1227+
path = `${path}/**`;
12021228
}
1229+
1230+
let numOfFiles = globby.sync(path, {
1231+
dot: true,
1232+
onlyFiles: false,
1233+
cwd: `${this.argv.cwd}/${stateDir}/cache/${cacheName}`,
1234+
}).length;
1235+
1236+
if (numOfFiles == 0) {
1237+
writeStreams.stdout(chalk`{yellow WARNING: ${path}: no matching files. Ensure that the artifact path is relative to the working directory}\n`);
1238+
continue;
1239+
}
1240+
1241+
if (!globby.hasMagic(path)) numOfFiles++; // add one because the pattern itself is a folder
1242+
1243+
writeStreams.stdout(`${_path}: found ${numOfFiles} artifact files and directories\n`);
12031244
}
1245+
writeStreams.stdout(chalk`${this.formattedJobName} {magentaBright cache created in '${stateDir}/cache/${cacheName}'} in {magenta ${prettyHrtime(endTime)}}\n`);
12041246
}
12051247
}
12061248

src/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,25 @@ export class Utils {
328328
});
329329
}
330330

331+
static isSubpath (lhs: string, rhs: string, cwd: string = process.cwd()) {
332+
let absLhs = "";
333+
if (path.isAbsolute(lhs)) {
334+
absLhs = lhs;
335+
} else {
336+
absLhs = path.resolve(cwd, lhs);
337+
}
338+
339+
let absRhs = "";
340+
if (path.isAbsolute(rhs)) {
341+
absRhs = rhs;
342+
} else {
343+
absRhs = path.resolve(cwd, rhs);
344+
}
345+
346+
const relative = path.relative(absRhs, absLhs);
347+
return !relative.startsWith("..");
348+
}
349+
331350
static async rsyncTrackedFiles (cwd: string, stateDir: string, target: string): Promise<{hrdeltatime: [number, number]}> {
332351
const time = process.hrtime();
333352
await fs.mkdirp(`${cwd}/${stateDir}/builds/${target}`);

tests/test-cases/cache-docker/.gitlab-ci.yml

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,58 @@ variables:
44

55
produce-cache:
66
stage: build
7-
image: docker.io/library/alpine
7+
image: busybox
88
cache:
99
key: $VALUE
10-
paths: [.cache]
11-
script: mkdir -p .cache && touch .cache/file1
10+
paths:
11+
- .cache
12+
- .cache/
13+
- .cache/*
14+
- .cache/**
15+
- .cache2/*/bar
16+
- .cache2/**/bar
17+
- .cache3
18+
- /tmp
19+
script:
20+
- |
21+
# creating 4 files/folder that should match .cache
22+
mkdir -p .cache
23+
touch .cache/file1
24+
touch .cache/file2
25+
touch .cache/.hiddenfile
26+
27+
- |
28+
# creating 4 files/folder that should match .cache2/*/bar
29+
mkdir -p .cache2/foo/bar
30+
touch .cache2/foo/bar/file1
31+
touch .cache2/foo/bar/file2
32+
touch .cache2/foo/bar/.hiddenfile
33+
34+
- |
35+
# creating files in .cache2 which should not match .cache2/*.bar
36+
touch .cache2/a
37+
touch .cache2/b
38+
mkdir -p .cache2/foo/bazz
39+
touch .cache2/foo/bazz/a
1240
1341
consume-cache:
1442
stage: test
15-
image: docker.io/library/alpine
43+
image: busybox
1644
needs: [produce-cache]
1745
dependencies: [produce-cache] # testing the absence of artifacts
1846
cache:
1947
key: maven
2048
paths: [.cache]
2149
policy: pull
22-
script: if [ ! -f .cache/file1 ]; then exit 1; fi
50+
script:
51+
- test -f .cache/file1
52+
- test -f .cache/file2
53+
- test -f .cache/.hiddenfile
54+
55+
- test -f .cache2/foo/bar/file1
56+
- test -f .cache2/foo/bar/file2
57+
- test -f .cache2/foo/bar/.hiddenfile
58+
59+
- "! test -f .cache2/a"
60+
- "! test -f .cache2/b"
61+
- "! test -f .cache2/foo/bazz/a"

tests/test-cases/cache-docker/integration.test.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,51 @@ import {handler} from "../../../src/handler.js";
33
import fs from "fs-extra";
44
import {initSpawnSpy} from "../../mocks/utils.mock.js";
55
import {WhenStatics} from "../../mocks/when-statics.js";
6+
import {basename} from "node:path/posix";
7+
import {dirname} from "node:path";
68

79
beforeAll(() => {
810
initSpawnSpy(WhenStatics.all);
911
});
1012

11-
test.concurrent("cache-docker <consume-cache> --needs", async () => {
12-
await fs.rm("tests/test-cases/cache-docker/.gitlab-ci-local/cache/", {recursive: true, force: true});
13+
const name = basename(dirname(import.meta.url));
14+
const cwd = `tests/test-cases/${name}`;
15+
16+
describe(name, () => {
1317
const writeStreams = new WriteStreamsMock();
14-
await handler({
15-
cwd: "tests/test-cases/cache-docker",
16-
job: ["consume-cache"],
17-
needs: true,
18-
}, writeStreams);
19-
expect(writeStreams.stdoutLines.join("\n")).toEqual(expect.stringMatching(/exported cache .cache 'maven'/));
18+
beforeAll(async () => {
19+
await fs.rm(`${cwd}/.gitlab-ci-local/cache/`, {recursive: true, force: true}); // to ensure that the cache from previous runs gets deleted
20+
21+
await handler({
22+
cwd,
23+
noColor: true,
24+
file: ".gitlab-ci.yml",
25+
}, writeStreams);
26+
});
27+
28+
it("should show export cache message", () => {
29+
expect(writeStreams.stdoutLines.join("\n")).toContain("produce-cache cache created in '.gitlab-ci-local/cache/maven'");
30+
});
31+
32+
it("should show the correct number of files that's exported", () => {
33+
expect(writeStreams.stdoutLines.join("\n")).toContain(".cache: found 4 artifact files and directories");
34+
expect(writeStreams.stdoutLines.join("\n")).toContain(".cache/: found 4 artifact files and directories");
35+
expect(writeStreams.stdoutLines.join("\n")).toContain(".cache/*: found 3 artifact files and directories");
36+
// NOTE: gitlab.com shows .cache/**: found 7 matching artifact files and directories
37+
// i can't make any sense of it, i think it's probably a bug?
38+
expect(writeStreams.stdoutLines.join("\n")).toContain(".cache/**: found 3 artifact files and directories");
39+
expect(writeStreams.stdoutLines.join("\n")).toContain(".cache2/*/bar: found 4 artifact files and directories");
40+
expect(writeStreams.stdoutLines.join("\n")).toContain(".cache2/**/bar: found 4 artifact files and directories");
41+
expect(writeStreams.stdoutLines.join("\n")).toContain("WARNING: .cache3: no matching files. Ensure that the artifact path is relative to the working directory");
42+
expect(writeStreams.stdoutLines.join("\n")).toContain("WARNING: processPath: artifact path is not a subpath of project directory: /tmp");
43+
});
44+
45+
it("should export cache to local fs", () => {
46+
expect(fs.existsSync(`${cwd}/.gitlab-ci-local/cache/maven`)).toBe(true);
47+
});
48+
49+
it("should be pull the cache with the expected content", () => {
50+
// The assertions are done via the consume-cache job's script
51+
expect(writeStreams.stdoutLines.join("\n")).toContain("PASS consume-cache");
52+
});
2053
});

tests/test-cases/cache-key-files/integration.test.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ import {handler} from "../../../src/handler.js";
33
import fs from "fs-extra";
44
import {initSpawnSpy} from "../../mocks/utils.mock.js";
55
import {WhenStatics} from "../../mocks/when-statics.js";
6-
import chalk from "chalk";
76

87
beforeAll(() => {
98
initSpawnSpy(WhenStatics.all);
109
});
1110

12-
test.concurrent("cache-key-files <consume-cache> --shell-isolation --needs", async () => {
11+
test("cache-key-files <consume-cache> --shell-isolation --needs", async () => {
1312
await fs.rm("tests/test-cases/cache-key-files/.gitlab-ci-local/cache/", {recursive: true, force: true});
1413
const writeStreams = new WriteStreamsMock();
1514
await handler({
@@ -27,24 +26,22 @@ test("cache-key-files <cache-key-file referencing $CI_PROJECT_DIR>", async () =>
2726
await handler({
2827
cwd: "tests/test-cases/cache-key-files",
2928
job: ["cache-key-file referencing $CI_PROJECT_DIR"],
29+
noColor: true,
3030
}, writeStreams);
3131

32-
const expected = [
33-
chalk`{blueBright cache-key-file referencing $CI_PROJECT_DIR} {magentaBright exported cache fakepackage.json 'md-8aaa60c7b3009df8ce6973111af131bbcde5636e'}`,
34-
];
32+
const expected = "cache-key-file referencing $CI_PROJECT_DIR cache created in '.gitlab-ci-local/cache/0_/builds/gcl/test-project/fakepackage-8aaa60c7b3009df8ce6973111af131bbcde5636e'";
3533

36-
expect(writeStreams.stdoutLines.join("\n")).toContain(expected.join("\n"));
34+
expect(writeStreams.stdoutLines.join("\n")).toContain(expected);
3735
});
3836

3937
test("cache-key-files <cache-key-file file dont exist>", async () => {
4038
const writeStreams = new WriteStreamsMock();
4139
await handler({
4240
cwd: "tests/test-cases/cache-key-files",
4341
job: ["cache-key-file file dont exist"],
42+
noColor: true,
4443
}, writeStreams);
4544

46-
const expected = [
47-
chalk`{blueBright cache-key-file file dont exist} {magentaBright exported cache /tmp 'md-18bbe9d7603e540e28418cf4a072938ac477a2c1'}`,
48-
];
49-
expect(writeStreams.stdoutLines.join("\n")).toContain(expected.join("\n"));
45+
const expected = "cache-key-file file dont exist cache created in '.gitlab-ci-local/cache/0_no-such-file-18bbe9d7603e540e28418cf4a072938ac477a2c1'";
46+
expect(writeStreams.stdoutLines.join("\n")).toContain(expected);
5047
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
development-cache:
3+
image: busybox
4+
script:
5+
- mkdir -p cached
6+
- echo "development" > cached/value.txt
7+
cache:
8+
paths:
9+
- cached/
10+
key:
11+
prefix: development
12+
files:
13+
- Dockerfile
14+
15+
production-cache:
16+
image: busybox
17+
script:
18+
- mkdir -p cached
19+
- echo "production" > cached/value.txt
20+
cache:
21+
paths:
22+
- cached/
23+
key:
24+
prefix: production
25+
files:
26+
- Dockerfile
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
rspec:
3+
image: busybox
4+
script:
5+
- echo "This rspec job uses a cache."
6+
cache:
7+
key:
8+
files:
9+
- Gemfile.lock
10+
prefix: $CI_JOB_NAME
11+
paths:
12+
- vendor/ruby

0 commit comments

Comments
 (0)