diff --git a/src/argv.ts b/src/argv.ts index 6c0ccaeba..e73c17b54 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -337,4 +337,8 @@ export class Argv { get childPipelineDepth (): number { return this.map.get("childPipelineDepth"); } + + get registry (): boolean { + return this.map.get("registry") ?? false; + } } diff --git a/src/handler.ts b/src/handler.ts index bdbbe530a..37a5673df 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -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) { @@ -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); @@ -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); @@ -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); } diff --git a/src/index.ts b/src/index.ts index 379f97345..9df828ad0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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("-")) { diff --git a/src/job.ts b/src/job.ts index e428fa4f5..8afd04be9 100644 --- a/src/job.ts +++ b/src/job.ts @@ -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; } @@ -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) { @@ -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} `; diff --git a/src/utils.ts b/src/utils.ts index e61524bee..bf459f921 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 { + 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 { + 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", + ]); + } + + static async stopDockerRegistry (containerExecutable: string): Promise { + await Utils.spawn([containerExecutable, "rm", "-f", this.gclRegistryPrefix]); + } } diff --git a/tests/test-cases/local-registry/.gitlab-ci.yml b/tests/test-cases/local-registry/.gitlab-ci.yml new file mode 100644 index 000000000..158a0b6ae --- /dev/null +++ b/tests/test-cases/local-registry/.gitlab-ci.yml @@ -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 diff --git a/tests/test-cases/local-registry/integration.test.ts b/tests/test-cases/local-registry/integration.test.ts new file mode 100644 index 000000000..7f850ed43 --- /dev/null +++ b/tests/test-cases/local-registry/integration.test.ts @@ -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 ", 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 ", 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)); +});