Skip to content

feat: option to start a local docker registry #1601

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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 @@ -337,4 +337,8 @@ export class Argv {
get childPipelineDepth (): number {
return this.map.get("childPipelineDepth");
}

get registry (): boolean {
return this.map.get("registry") ?? false;
}
}
12 changes: 12 additions & 0 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[
Commander.runCsv(parser, writeStreams, argv.listCsvAll);
} else if (argv.job.length > 0) {
assert(argv.stage === null, "You cannot use --stage when starting individual jobs");
if (argv.registry) {
await Utils.startDockerRegistry(argv);
}
generateGitIgnore(cwd, stateDir);
const time = process.hrtime();
if (argv.needs || argv.onlyNeeds) {
Expand All @@ -77,6 +80,9 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[
writeStreams.stderr(chalk`{grey pipeline finished} in {grey ${prettyHrtime(process.hrtime(time))}}\n`);
}
} else if (argv.stage) {
if (argv.registry) {
await Utils.startDockerRegistry(argv);
}
generateGitIgnore(cwd, stateDir);
const time = process.hrtime();
const pipelineIid = await state.getPipelineIid(cwd, stateDir);
Expand All @@ -85,6 +91,9 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[
await Commander.runJobsInStage(argv, parser, writeStreams);
writeStreams.stderr(chalk`{grey pipeline finished} in {grey ${prettyHrtime(process.hrtime(time))}}\n`);
} else {
if (argv.registry) {
await Utils.startDockerRegistry(argv);
}
generateGitIgnore(cwd, stateDir);
const time = process.hrtime();
await state.incrementPipelineIid(cwd, stateDir);
Expand All @@ -96,5 +105,8 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[
}
writeStreams.flush();

if (argv.registry) {
await Utils.stopDockerRegistry(argv.containerExecutable);
}
return cleanupJobResources(jobs);
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,11 @@ process.on("SIGUSR2", async () => await cleanupJobResources(jobs));
default: true,
description: "Enables color",
})
.option("registry", {
type: "boolean",
requiresArg: false,
description: "Start a local docker registry and configure gitlab-ci-local containers to use that by default",
})
.completion("completion", false, (current: string, yargsArgv: any, completionFilter: any, done: (completions: string[]) => any) => {
try {
if (current.startsWith("-")) {
Expand Down
13 changes: 12 additions & 1 deletion src/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ export class Job {
predefinedVariables["CI_NODE_INDEX"] = `${opt.nodeIndex}`;
}
predefinedVariables["CI_NODE_TOTAL"] = `${opt.nodesTotal}`;
predefinedVariables["CI_REGISTRY"] = `local-registry.${this.gitData.remote.host}`;
predefinedVariables["CI_REGISTRY"] = predefinedVariables["CI_REGISTRY"] = this.argv.registry ? Utils.gclRegistryPrefix : `local-registry.${this.gitData.remote.host}`;
predefinedVariables["CI_REGISTRY_IMAGE"] = `$CI_REGISTRY/${predefinedVariables["CI_PROJECT_PATH"].toLowerCase()}`;
return predefinedVariables;
}
Expand Down Expand Up @@ -829,6 +829,11 @@ export class Job {
});
}

if (this.argv.registry) {
expanded["CI_REGISTRY_USER"] = expanded["CI_REGISTRY_USER"] ?? `${Utils.gclRegistryPrefix}.user`;
expanded["CI_REGISTRY_PASSWORD"] = expanded["CI_REGISTRY_PASSWORD"] ?? `${Utils.gclRegistryPrefix}.password`;
}

this.refreshLongRunningSilentTimeout(writeStreams);

if (imageName && !this._containerId) {
Expand Down Expand Up @@ -893,6 +898,12 @@ export class Job {
dockerCmd += `--network ${this._serviceNetworkId} --network-alias build `;
}

if (this.argv.registry) {
dockerCmd += `--network ${Utils.gclRegistryPrefix}.net `;
dockerCmd += `--volume ${Utils.gclRegistryPrefix}.certs:/etc/containers/certs.d:ro `;
dockerCmd += `--volume ${Utils.gclRegistryPrefix}.certs:/etc/docker/certs.d:ro `;
}

dockerCmd += `--volume ${buildVolumeName}:${this.ciProjectDir} `;
dockerCmd += `--volume ${tmpVolumeName}:${this.fileVariablesDir} `;
dockerCmd += `--workdir ${this.ciProjectDir} `;
Expand Down
77 changes: 77 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,4 +434,81 @@ export class Utils {
// https://dev.to/babak/exhaustive-type-checking-with-typescript-4l3f
throw new Error(`Unhandled case ${param}`);
}

static async dockerVolumeFileExists (containerExecutable: string, path: string, volume: string): Promise<boolean> {
try {
await Utils.spawn([containerExecutable, "run", "--rm", "-v", `${volume}:/mnt/vol`, "alpine", "ls", `/mnt/vol/${path}`]);
return true;
} catch {
return false;
}
}

static gclRegistryPrefix: string = "registry.gcl.local";
static async startDockerRegistry (argv: Argv): Promise<void> {
const gclRegistryCertVol = `${this.gclRegistryPrefix}.certs`;
const gclRegistryDataVol = `${this.gclRegistryPrefix}.data`;
const gclRegistryNet = `${this.gclRegistryPrefix}.net`;

// create cert volume
try {
await Utils.spawn(`${argv.containerExecutable} volume create ${gclRegistryCertVol}`.split(" "));
} catch (err) {
if (err instanceof Error && !err.message.endsWith("already exists"))
throw err;
}

// create self-signed cert/key files for https support
if (!await this.dockerVolumeFileExists(argv.containerExecutable, `${this.gclRegistryPrefix}.crt`, gclRegistryCertVol)) {
const opensslArgs = [
"req", "-newkey", "rsa:4096", "-nodes", "-sha256",
"-keyout", `/certs/${this.gclRegistryPrefix}.key`,
"-x509", "-days", "365",
"-out", `/certs/${this.gclRegistryPrefix}.crt`,
"-subj", `/CN=${this.gclRegistryPrefix}`,
"-addext", `subjectAltName=DNS:${this.gclRegistryPrefix}`,
];
const generateCertsInPlace = [
argv.containerExecutable, "run", "--rm", "-v", `${gclRegistryCertVol}:/certs`, "--entrypoint", "sh", "alpine/openssl", "-c",
[
"openssl", ...opensslArgs,
"&&", "mkdir", "-p", `/certs/${this.gclRegistryPrefix}`,
"&&", "cp", `/certs/${this.gclRegistryPrefix}.crt`, `/certs/${this.gclRegistryPrefix}/ca.crt`,
].join(" "),
];
await Utils.spawn(generateCertsInPlace);
}

// create data volume
try {
await Utils.spawn([argv.containerExecutable, "volume", "create", gclRegistryDataVol]);
} catch (err) {
if (err instanceof Error && !err.message.endsWith("already exists"))
throw err;
}

// create network
try {
await Utils.spawn([argv.containerExecutable, "network", "create", gclRegistryNet]);
} catch (err) {
if (err instanceof Error && !err.message.includes("already exists"))
throw err;
}

await Utils.spawn([argv.containerExecutable, "rm", "-f", this.gclRegistryPrefix]);
await Utils.spawn([
argv.containerExecutable, "run", "-d", "--name", this.gclRegistryPrefix,
"--network", gclRegistryNet,
"--volume", `${gclRegistryDataVol}:/var/lib/registry`,
"--volume", `${gclRegistryCertVol}:/certs:ro`,
"-e", "REGISTRY_HTTP_ADDR=0.0.0.0:443",
"-e", `REGISTRY_HTTP_TLS_CERTIFICATE=/certs/${this.gclRegistryPrefix}.crt`,
"-e", `REGISTRY_HTTP_TLS_KEY=/certs/${this.gclRegistryPrefix}.key`,
"registry",
]);
}
Copy link
Owner

@firecow firecow Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@superewald Without a wait-for-port you risk jobs start pushing to the registry before it is ready.


static async stopDockerRegistry (containerExecutable: string): Promise<void> {
await Utils.spawn([containerExecutable, "rm", "-f", this.gclRegistryPrefix]);
}
}
17 changes: 17 additions & 0 deletions tests/test-cases/local-registry/.gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
registry-variables:
image: alpine:latest
script:
- echo "CI_REGISTRY=$CI_REGISTRY"
- echo "CI_REGISTRY_USER=$CI_REGISTRY_USER"
- echo "CI_REGISTRY_PASSWORD=$CI_REGISTRY_PASSWORD"

registry-login-docker:
image: docker:dind
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY

registry-login-oci:
image: quay.io/podman/stable
script:
- echo "$CI_REGISTRY_PASSWORD" | podman login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
54 changes: 54 additions & 0 deletions tests/test-cases/local-registry/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {WriteStreamsMock} from "../../../src/write-streams.js";
import {handler} from "../../../src/handler.js";
import {Utils} from "../../../src/utils.js";
import chalk from "chalk";

test("local-registry ci variables", async () => {
const writeStreams = new WriteStreamsMock;
await handler({
cwd: "tests/test-cases/local-registry",
job: ["registry-variables"],
registry: true,
}, writeStreams);

const expected = [
chalk`{blueBright registry-variables} {greenBright >} CI_REGISTRY=${Utils.gclRegistryPrefix}`,
chalk`{blueBright registry-variables} {greenBright >} CI_REGISTRY_USER=${Utils.gclRegistryPrefix}.user`,
chalk`{blueBright registry-variables} {greenBright >} CI_REGISTRY_PASSWORD=${Utils.gclRegistryPrefix}.password`,
];

expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected));
});

test("local-registry login <docker>", async () => {
const writeStreams = new WriteStreamsMock();
await handler({
cwd: "tests/test-cases/local-registry",
job: ["registry-login-docker"],
registry: true,
}, writeStreams);


const expected = [
chalk`{blueBright registry-login-docker} {greenBright >} Login Succeeded`,
];

expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected));
});

test("local-registry login <oci>", async () => {
const writeStreams = new WriteStreamsMock();
await handler({
cwd: "tests/test-cases/local-registry",
job: ["registry-login-oci"],
registry: true,
privileged: true,
}, writeStreams);


const expected = [
chalk`{blueBright registry-login-oci} {greenBright >} Login Succeeded!`,
];

expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected));
});