UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

374 lines 16.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DockerClient = void 0; exports.exportableProfileName = exportableProfileName; exports.buildCplnEnv = buildCplnEnv; exports.waitForExit = waitForExit; const child_process_1 = require("child_process"); const retry_1 = require("./retry"); const logger_1 = require("../util/logger"); const time_1 = require("../util/time"); // ANCHOR - Constants /** How long after 'exit' to wait for 'close' before settling. */ const EXIT_CLOSE_GRACE_MS = 2 * 1000; const DEFAULT_DEPS = { spawn: child_process_1.spawn, sleep: time_1.sleep, notify: (message) => { process.stderr.write(message + '\n'); }, forwardStderr: (chunk) => { process.stderr.write(chunk); }, }; // ANCHOR - Client /** * Docker CLI client that encapsulates all Docker interactions. * Handles buildx detection and fallback to legacy docker build, and retries * registry-facing invocations (push, pull, buildx --push) on transient * proxy / connection-closed failures. */ class DockerClient { constructor(profile, deps = DEFAULT_DEPS) { this.profileName = exportableProfileName(profile); this.deps = deps; } // Public Static Methods // /** * Checks whether Docker is usable on this machine. * @param {() => void} [run] - Probe to execute. Defaults to `docker version`. * @returns {DockerAvailability} 'ok' if usable, 'missing' if the binary is not found, 'daemon-down' if the CLI exists but the daemon is unreachable. */ static checkAvailability(run = () => (0, child_process_1.execFileSync)('docker', ['version'], { stdio: 'ignore', timeout: 5000 }) // no shell, so the probe resolves exactly what exec's spawn can run ) { try { run(); return 'ok'; } catch (e) { return (e === null || e === void 0 ? void 0 : e.code) === 'ENOENT' ? 'missing' : 'daemon-down'; } } // Getters // /** * Checks if Docker Buildx is available (cached after first check). * @returns {boolean} True if buildx is available, false otherwise. */ get isBuildxAvailable() { if (this.buildxAvailable === undefined) { this.buildxAvailable = this.checkBuildx(); } return this.buildxAvailable; } // Public Methods // /** * Build a Docker image. Uses buildx if available, falls back to legacy docker build. * @param {DockerBuildOptions} opts - What the build command receives, resolved and ready. * @param {NodeJS.ProcessEnv} env - (Optional) Our values merged into the docker child's environment. * @returns {Promise<void>} Resolves when build completes successfully. */ async build(opts, env = {}) { const multiPlatform = opts.platform.includes(','); if (multiPlatform && !this.isBuildxAvailable) { throw new Error(`Multi-platform builds require Docker Buildx, which is not installed.\n\n` + `To install Buildx, run:\n` + ` curl -fsSL "https://github.com/docker/buildx/releases/download/v0.19.3/buildx-v0.19.3.linux-amd64" \\\n` + ` -o ~/.docker/cli-plugins/docker-buildx && chmod +x ~/.docker/cli-plugins/docker-buildx\n\n` + `Or visit: https://docs.docker.com/build/install-buildx/`); } if (this.isBuildxAvailable) { await this.buildWithBuildx(opts, env); return; } if (opts.secrets && opts.secrets.length > 0) { throw new Error(`ERROR: Build secrets require Docker Buildx, which is not installed.\n\n` + `To install Buildx, visit: https://docs.docker.com/build/install-buildx/\n` + `Or re-run with --remote to build on the Control Plane build service.`); } await this.buildLegacy(opts, env); } /** * Push an image to the registry, retrying on transient proxy / connection failures. * @param {string} image - The image name to push. * @returns {Promise<void>} Resolves when push completes successfully. */ async push(image) { await this.execWithRetry(['push', '--disable-content-trust', image]); } /** * Pull an image from the registry, retrying on transient proxy / connection failures. * @param {string} image - The image name to pull. * @returns {Promise<void>} Resolves when pull completes successfully. */ async pull(image) { await this.execWithRetry(['pull', image]); } /** * Tag an image with a new name. * @param {string} source - The source image name. * @param {string} target - The target image name. * @returns {Promise<void>} Resolves when tagging completes successfully. */ async tag(source, target) { await this.exec(['tag', source, target]); } /** * Remove one or more images from the local Docker daemon. * @param {string[]} images - The image names to remove. * @returns {Promise<void>} Resolves when removal completes successfully. */ async remove(...images) { await this.exec(['rmi', ...images]); } // Private Methods // /** * Build using Docker Buildx (preferred method with full feature support). * @param {DockerBuildOptions} opts - What the build command receives. * @param {NodeJS.ProcessEnv} env - Our values merged into the docker child's environment. * @returns {Promise<void>} Resolves when build completes successfully. */ async buildWithBuildx(opts, env) { if (!opts.push) { logger_1.logger.info(`Using --load to import the image into the local Docker daemon (use --push to publish to registry instead)`); } const args = this.assembleBuildArgs(['buildx', 'build'], [opts.push ? '--push' : '--load'], opts); logger_1.logger.debug(`arguments passed to docker ${args.join(' ')}`); if (opts.push) { await this.execWithRetry(args, env); } else { await this.exec(args, env); } } /** * Build using legacy docker build (fallback when buildx is not available). * @param {DockerBuildOptions} opts - What the build command receives. * @param {NodeJS.ProcessEnv} env - Our values merged into the docker child's environment. * @returns {Promise<void>} Resolves when build completes successfully. */ async buildLegacy(opts, env) { logger_1.logger.warn(`Docker Buildx is not installed. Falling back to legacy 'docker build'.\n` + `Note: Some features like multi-platform builds are not available without Buildx.\n` + `To install Buildx, visit: https://docs.docker.com/build/install-buildx/`); const args = this.assembleBuildArgs(['build'], [], opts); logger_1.logger.debug(`arguments passed to docker ${args.join(' ')}`); await this.exec(args, env); if (opts.push) { await this.push(opts.nameAndTag); } } /** * Assembles the argv a build shares across builders: the builder command, the * dockerfile and platform, the mode flags, the cache policy, the build args * and secrets, and the tagged context last. * * @param {string[]} command - The builder command, e.g. ['buildx', 'build']. * @param {string[]} modeFlags - Flags specific to the builder invocation. * @param {DockerBuildOptions} opts - What the build command receives. * @returns {string[]} The complete docker argv. */ assembleBuildArgs(command, modeFlags, opts) { const args = [...command, '-f', opts.dockerfilePath, '--platform', opts.platform, ...modeFlags]; if (opts.noCache) { args.push('--no-cache'); } args.push(...this.buildArgOptions(opts)); args.push(...this.secretOptions(opts)); args.push('-t', opts.nameAndTag, opts.path); return args; } /** * The --build-arg options: one per configured pair. * @param {DockerBuildOptions} opts - What the build command receives. * @returns {string[]} The options, tokenized for the command. */ buildArgOptions(opts) { var _a; const options = []; for (const [name, value] of Object.entries((_a = opts.buildArgs) !== null && _a !== void 0 ? _a : {})) { options.push('--build-arg', `${name}=${value}`); } return options; } /** * The --secret options: one per configured value. * @param {DockerBuildOptions} opts - What the build command receives. * @returns {string[]} The options, tokenized for the command. */ secretOptions(opts) { var _a; const options = []; for (const secret of (_a = opts.secrets) !== null && _a !== void 0 ? _a : []) { options.push('--secret', secret); } return options; } /** * Check if Docker Buildx is installed. * @returns {boolean} True if buildx is available, false otherwise. */ checkBuildx() { try { (0, child_process_1.execSync)('docker buildx version', { stdio: 'ignore' }); return true; } catch (_a) { return false; } } /** * Execute a registry-facing Docker command, retrying when the failure classifies * as a transient proxy / connection error. * @param {string[]} args - Command arguments to pass to docker. * @param {NodeJS.ProcessEnv | undefined} env - (Optional) Values merged over the CPLN environment for the child. * @returns {Promise<void>} Resolves on success, rejects with the final failure otherwise. */ async execWithRetry(args, env) { var _a; for (let attempt = 1;; attempt++) { try { await this.execCaptured(args, env); return; } catch (error) { if (!(error instanceof retry_1.DockerExecError)) { throw error; } // A signal-terminated child (e.g. Ctrl-C) is never retried if (error.exitCode === null) { throw error; } const classification = (0, retry_1.classifyRegistryError)(error.stderrTail); const maxAttempts = Math.min(retry_1.MAX_TRANSIENT_ATTEMPTS, (_a = classification.attemptCap) !== null && _a !== void 0 ? _a : retry_1.MAX_TRANSIENT_ATTEMPTS); if (classification.verdict !== 'retryable' || attempt >= maxAttempts) { if (classification.verdict === 'retryable' && attempt > 1) { error.message = `${error.command} failed after ${attempt} attempts: ${error.message}`; } throw error; } const delayMs = (0, retry_1.retryDelayMs)(attempt); this.deps.notify(`${error.command} failed (${classification.reason}); retrying in ${delayMs / 1000}s (attempt ${attempt + 1} of ${maxAttempts})`); await this.deps.sleep(delayMs); } } } /** * Execute a Docker command with stderr teed to the terminal and a bounded tail * buffered for failure classification. * @param {string[]} args - Command arguments to pass to docker. * @param {NodeJS.ProcessEnv | undefined} env - (Optional) Values merged over the CPLN environment for the child. * @returns {Promise<void>} Resolves on exit code 0, rejects with DockerExecError otherwise. */ execCaptured(args, env) { const command = (0, retry_1.describeDockerCommand)(args); return new Promise((resolve, reject) => { var _a; const proc = this.deps.spawn('docker', args, { stdio: ['inherit', process.stderr, 'pipe'], env: { ...buildCplnEnv(this.profileName), ...env }, }); let stderrTail = ''; let settled = false; let graceTimer; const settle = (finish) => { if (settled) { return; } settled = true; if (graceTimer) { clearTimeout(graceTimer); } finish(); }; const finishWith = (code, signal) => { if (code === 0) { resolve(); } else { reject(new retry_1.DockerExecError({ command, stderrTail, exitCode: code, signal })); } }; (_a = proc.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => { this.deps.forwardStderr(chunk); stderrTail = (0, retry_1.appendToStderrTail)(stderrTail, chunk.toString()); }); proc.on('error', (error) => { settle(() => reject(error)); }); // Settle from 'exit' after a grace period in case a leaked stderr fd keeps 'close' from firing proc.on('exit', (code, signal) => { graceTimer = setTimeout(() => { settle(() => finishWith(code, signal)); }, EXIT_CLOSE_GRACE_MS); }); proc.on('close', (code, signal) => { settle(() => finishWith(code, signal)); }); }); } /** * Execute a Docker command with output streaming straight to the terminal. * @param {string[]} args - Command arguments to pass to docker. * @param {NodeJS.ProcessEnv | undefined} env - (Optional) Values merged over the CPLN environment for the child. * @returns {Promise<void>} Resolves when command completes successfully. */ exec(args, env) { const proc = this.deps.spawn('docker', args, { stdio: ['inherit', process.stderr, process.stderr], env: { ...buildCplnEnv(this.profileName), ...env }, }); return waitForExit(proc, (0, retry_1.describeDockerCommand)(args)); } } exports.DockerClient = DockerClient; // SECTION - Helpers /** * Returns the profile name to export to a subprocess. A synthesized token-only profile * has no stored profile for the credential helper to find, so it is never exported — * the helper resolves the inherited CPLN_TOKEN instead. * @param {Profile | undefined} profile - (Optional) The session profile. * @returns {string | undefined} The exportable profile name. */ function exportableProfileName(profile) { if (!profile || profile.synthesized) { return undefined; } return profile.name; } /** * Builds the environment for a registry-facing subprocess, exporting the profile name * as CPLN_PROFILE so docker-credential-cpln authenticates as the session's profile. * @param {string | undefined} profileName - (Optional) The profile name to export. * @returns {NodeJS.ProcessEnv} The environment for the subprocess. */ function buildCplnEnv(profileName) { const env = { ...process.env }; if (profileName) { env.CPLN_PROFILE = profileName; } return env; } /** * Waits for a child process whose output streams straight to the terminal. * @param {ChildProcess} proc - The child process to wait for. * @param {string} command - Command label used in failure messages. * @returns {Promise<void>} Resolves when the process exits with code 0, rejects otherwise. */ function waitForExit(proc, command) { return new Promise((resolve, reject) => { proc.on('exit', (code, signal) => { if (code === 0) { resolve(); } else if (code === null) { reject(new Error(`${command} terminated by signal ${signal}`)); } else { reject(new Error(`${command} exited with code ${code}`)); } }); proc.on('error', reject); }); } // !SECTION //# sourceMappingURL=client.js.map