Skip to content

Commit 085946c

Browse files
authored
feat: option to start a local docker registry (#1601)
* feat: option to start local docker registry * fix linting errors * fix already exist check for container errors * run oci test container in privileged mode * fix $CI_REGISTRY[..] variable scopes * add wait-for-port check * fix eslint * fix eslint * fix(tests): use nocolor stream comparison
1 parent c5a7e6d commit 085946c

7 files changed

Lines changed: 192 additions & 2 deletions

File tree

src/argv.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,4 +361,8 @@ export class Argv {
361361
get childPipelineDepth (): number {
362362
return this.map.get("childPipelineDepth");
363363
}
364+
365+
get registry (): boolean {
366+
return this.map.get("registry") ?? false;
367+
}
364368
}

src/handler.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[
6969
Commander.runCsv(parser, writeStreams, argv.listCsvAll);
7070
} else if (argv.job.length > 0) {
7171
assert(argv.stage === null, "You cannot use --stage when starting individual jobs");
72+
if (argv.registry) {
73+
await Utils.startDockerRegistry(argv);
74+
}
7275
generateGitIgnore(cwd, stateDir);
7376
const time = process.hrtime();
7477
if (argv.needs || argv.onlyNeeds) {
@@ -82,6 +85,9 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[
8285
writeStreams.stderr(chalk`{grey pipeline finished} in {grey ${prettyHrtime(process.hrtime(time))}}\n`);
8386
}
8487
} else if (argv.stage) {
88+
if (argv.registry) {
89+
await Utils.startDockerRegistry(argv);
90+
}
8591
generateGitIgnore(cwd, stateDir);
8692
const time = process.hrtime();
8793
const pipelineIid = await state.getPipelineIid(cwd, stateDir);
@@ -90,6 +96,9 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[
9096
await Commander.runJobsInStage(argv, parser, writeStreams);
9197
writeStreams.stderr(chalk`{grey pipeline finished} in {grey ${prettyHrtime(process.hrtime(time))}}\n`);
9298
} else {
99+
if (argv.registry) {
100+
await Utils.startDockerRegistry(argv);
101+
}
93102
generateGitIgnore(cwd, stateDir);
94103
const time = process.hrtime();
95104
await state.incrementPipelineIid(cwd, stateDir);
@@ -101,5 +110,8 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[
101110
}
102111
writeStreams.flush();
103112

113+
if (argv.registry) {
114+
await Utils.stopDockerRegistry(argv.containerExecutable);
115+
}
104116
return cleanupJobResources(jobs);
105117
}

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,11 @@ process.on("SIGUSR2", async () => await cleanupJobResources(jobs));
335335
default: true,
336336
description: "Enables color",
337337
})
338+
.option("registry", {
339+
type: "boolean",
340+
requiresArg: false,
341+
description: "Start a local docker registry and configure gitlab-ci-local containers to use that by default",
342+
})
338343
.completion("completion", false, (current: string, yargsArgv: any, completionFilter: any, done: (completions: string[]) => any) => {
339344
try {
340345
if (current.startsWith("-")) {

src/job.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ If you know what you're doing and would like to suppress this warning, use one o
339339
predefinedVariables["CI_NODE_INDEX"] = `${opt.nodeIndex}`;
340340
}
341341
predefinedVariables["CI_NODE_TOTAL"] = `${opt.nodesTotal}`;
342-
predefinedVariables["CI_REGISTRY"] = `local-registry.${this.gitData.remote.host}`;
342+
predefinedVariables["CI_REGISTRY"] = predefinedVariables["CI_REGISTRY"] = this.argv.registry ? Utils.gclRegistryPrefix : `local-registry.${this.gitData.remote.host}`;
343343
predefinedVariables["CI_REGISTRY_IMAGE"] = `$CI_REGISTRY/${predefinedVariables["CI_PROJECT_PATH"].toLowerCase()}`;
344344
return predefinedVariables;
345345
}
@@ -909,6 +909,11 @@ If you know what you're doing and would like to suppress this warning, use one o
909909
});
910910
}
911911

912+
if (this.argv.registry) {
913+
expanded["CI_REGISTRY_USER"] = expanded["CI_REGISTRY_USER"] ?? `${Utils.gclRegistryPrefix}.user`;
914+
expanded["CI_REGISTRY_PASSWORD"] = expanded["CI_REGISTRY_PASSWORD"] ?? `${Utils.gclRegistryPrefix}.password`;
915+
}
916+
912917
this.refreshLongRunningSilentTimeout(writeStreams);
913918

914919
if (imageName && !this._containerId) {
@@ -977,6 +982,12 @@ If you know what you're doing and would like to suppress this warning, use one o
977982
dockerCmd += `--network ${this._serviceNetworkId} --network-alias build `;
978983
}
979984

985+
if (this.argv.registry) {
986+
dockerCmd += `--network ${Utils.gclRegistryPrefix}.net `;
987+
dockerCmd += `--volume ${Utils.gclRegistryPrefix}.certs:/etc/containers/certs.d:ro `;
988+
dockerCmd += `--volume ${Utils.gclRegistryPrefix}.certs:/etc/docker/certs.d:ro `;
989+
}
990+
980991
dockerCmd += `--volume ${buildVolumeName}:${this.ciProjectDir} `;
981992
dockerCmd += `--volume ${tmpVolumeName}:${this.fileVariablesDir} `;
982993
dockerCmd += `--workdir ${this.ciProjectDir} `;

src/utils.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {Job, JobRule} from "./job.js";
55
import fs from "fs-extra";
66
import checksum from "checksum";
77
import base64url from "base64url";
8-
import execa from "execa";
8+
import execa, {ExecaError} from "execa";
99
import assert from "assert";
1010
import {CICDVariable} from "./variables-from-files.js";
1111
import {GitData} from "./git-data.js";
@@ -407,6 +407,101 @@ export class Utils {
407407
throw new Error(`Unhandled case ${param}`);
408408
}
409409

410+
static async dockerVolumeFileExists (containerExecutable: string, path: string, volume: string): Promise<boolean> {
411+
try {
412+
await Utils.spawn([containerExecutable, "run", "--rm", "-v", `${volume}:/mnt/vol`, "alpine", "ls", `/mnt/vol/${path}`]);
413+
return true;
414+
} catch {
415+
return false;
416+
}
417+
}
418+
419+
static gclRegistryPrefix: string = "registry.gcl.local";
420+
static async startDockerRegistry (argv: Argv): Promise<void> {
421+
const gclRegistryCertVol = `${this.gclRegistryPrefix}.certs`;
422+
const gclRegistryDataVol = `${this.gclRegistryPrefix}.data`;
423+
const gclRegistryNet = `${this.gclRegistryPrefix}.net`;
424+
425+
// create cert volume
426+
try {
427+
await Utils.spawn(`${argv.containerExecutable} volume create ${gclRegistryCertVol}`.split(" "));
428+
} catch (err) {
429+
if (err instanceof Error && !err.message.endsWith("already exists"))
430+
throw err;
431+
}
432+
433+
// create self-signed cert/key files for https support
434+
if (!await this.dockerVolumeFileExists(argv.containerExecutable, `${this.gclRegistryPrefix}.crt`, gclRegistryCertVol)) {
435+
const opensslArgs = [
436+
"req", "-newkey", "rsa:4096", "-nodes", "-sha256",
437+
"-keyout", `/certs/${this.gclRegistryPrefix}.key`,
438+
"-x509", "-days", "365",
439+
"-out", `/certs/${this.gclRegistryPrefix}.crt`,
440+
"-subj", `/CN=${this.gclRegistryPrefix}`,
441+
"-addext", `subjectAltName=DNS:${this.gclRegistryPrefix}`,
442+
];
443+
const generateCertsInPlace = [
444+
argv.containerExecutable, "run", "--rm", "-v", `${gclRegistryCertVol}:/certs`, "--entrypoint", "sh", "alpine/openssl", "-c",
445+
[
446+
"openssl", ...opensslArgs,
447+
"&&", "mkdir", "-p", `/certs/${this.gclRegistryPrefix}`,
448+
"&&", "cp", `/certs/${this.gclRegistryPrefix}.crt`, `/certs/${this.gclRegistryPrefix}/ca.crt`,
449+
].join(" "),
450+
];
451+
await Utils.spawn(generateCertsInPlace);
452+
}
453+
454+
// create data volume
455+
try {
456+
await Utils.spawn([argv.containerExecutable, "volume", "create", gclRegistryDataVol]);
457+
} catch (err) {
458+
if (err instanceof Error && !err.message.endsWith("already exists"))
459+
throw err;
460+
}
461+
462+
// create network
463+
try {
464+
await Utils.spawn([argv.containerExecutable, "network", "create", gclRegistryNet]);
465+
} catch (err) {
466+
if (err instanceof Error && !err.message.includes("already exists"))
467+
throw err;
468+
}
469+
470+
await Utils.spawn([argv.containerExecutable, "rm", "-f", this.gclRegistryPrefix]);
471+
await Utils.spawn([
472+
argv.containerExecutable, "run", "-d", "--name", this.gclRegistryPrefix,
473+
"--network", gclRegistryNet,
474+
"--volume", `${gclRegistryDataVol}:/var/lib/registry`,
475+
"--volume", `${gclRegistryCertVol}:/certs:ro`,
476+
"-e", "REGISTRY_HTTP_ADDR=0.0.0.0:443",
477+
"-e", `REGISTRY_HTTP_TLS_CERTIFICATE=/certs/${this.gclRegistryPrefix}.crt`,
478+
"-e", `REGISTRY_HTTP_TLS_KEY=/certs/${this.gclRegistryPrefix}.key`,
479+
"registry",
480+
]);
481+
482+
try {
483+
await execa(argv.containerExecutable, [
484+
"run", "--rm",
485+
"--network", gclRegistryNet,
486+
"--entrypoint", "sh",
487+
"curlimages/curl",
488+
"-c", `until [ "$(curl -s -o /dev/null -k -w "%{http_code}" https://${this.gclRegistryPrefix}:443)" = "200" ]; do sleep 1; done;`,
489+
], {
490+
timeout: 4000,
491+
});
492+
} catch (err) {
493+
await this.stopDockerRegistry(argv.containerExecutable);
494+
if ((err as ExecaError).timedOut) {
495+
throw "local docker registry port check timed out";
496+
}
497+
throw err;
498+
}
499+
}
500+
501+
static async stopDockerRegistry (containerExecutable: string): Promise<void> {
502+
await Utils.spawn([containerExecutable, "rm", "-f", this.gclRegistryPrefix]);
503+
}
504+
410505
static async getTrackedFiles (cwd: string): Promise<string[]> {
411506
const lsFilesRes = await Utils.bash("git ls-files --deduplicate", cwd);
412507
if (lsFilesRes.exitCode != 0) {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
registry-variables:
3+
image: alpine:latest
4+
script:
5+
- echo "CI_REGISTRY=$CI_REGISTRY"
6+
- echo "CI_REGISTRY_USER=$CI_REGISTRY_USER"
7+
- echo "CI_REGISTRY_PASSWORD=$CI_REGISTRY_PASSWORD"
8+
9+
registry-login-docker:
10+
image: docker:dind
11+
script:
12+
- echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
13+
14+
registry-login-oci:
15+
image: quay.io/podman/stable
16+
script:
17+
- echo "$CI_REGISTRY_PASSWORD" | podman login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {WriteStreamsMock} from "../../../src/write-streams.js";
2+
import {handler} from "../../../src/handler.js";
3+
import {Utils} from "../../../src/utils.js";
4+
5+
test("local-registry ci variables", async () => {
6+
const writeStreams = new WriteStreamsMock;
7+
await handler({
8+
cwd: "tests/test-cases/local-registry",
9+
job: ["registry-variables"],
10+
registry: true,
11+
noColor: true,
12+
}, writeStreams);
13+
14+
const expected = [
15+
`registry-variables > CI_REGISTRY=${Utils.gclRegistryPrefix}`,
16+
`registry-variables > CI_REGISTRY_USER=${Utils.gclRegistryPrefix}.user`,
17+
`registry-variables > CI_REGISTRY_PASSWORD=${Utils.gclRegistryPrefix}.password`,
18+
];
19+
20+
expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected));
21+
});
22+
23+
test("local-registry login <docker>", async () => {
24+
const writeStreams = new WriteStreamsMock();
25+
await handler({
26+
cwd: "tests/test-cases/local-registry",
27+
job: ["registry-login-docker"],
28+
registry: true,
29+
noColor: true,
30+
}, writeStreams);
31+
32+
expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(["registry-login-docker > Login Succeeded"]));
33+
});
34+
35+
test("local-registry login <oci>", async () => {
36+
const writeStreams = new WriteStreamsMock();
37+
await handler({
38+
cwd: "tests/test-cases/local-registry",
39+
job: ["registry-login-oci"],
40+
registry: true,
41+
privileged: true,
42+
noColor: true,
43+
}, writeStreams);
44+
45+
expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(["registry-login-oci > Login Succeeded!"]));
46+
});

0 commit comments

Comments
 (0)