UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

338 lines 13.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DockerClient = void 0; exports.exportableProfileName = exportableProfileName; exports.buildCplnEnv = buildCplnEnv; exports.waitForExit = waitForExit; const path = require("path"); 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 = 2000; 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.profile = profile; this.deps = deps; } // 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 - Build options including dockerfile, dir, image, platform, push, and noCache. * @returns {Promise<void>} Resolves when build completes successfully. */ async build(opts) { const dockerfilePath = opts.dockerfile === true ? path.join(opts.dir, 'Dockerfile') : opts.dockerfile; 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(dockerfilePath, opts); } else { await this.buildLegacy(dockerfilePath, opts); } } /** * Push an image to the registry, retrying on transient proxy / connection failures. * @param {string} image - The image name to push. * @param {string} [profileName] - Optional profile name to use for authentication. * @returns {Promise<void>} Resolves when push completes successfully. */ async push(image, profileName) { await this.execWithRetry(['push', '--disable-content-trust', image], profileName); } /** * Pull an image from the registry, retrying on transient proxy / connection failures. * @param {string} image - The image name to pull. * @param {string} [profileName] - Optional profile name to use for authentication. * @returns {Promise<void>} Resolves when pull completes successfully. */ async pull(image, profileName) { await this.execWithRetry(['pull', image], profileName); } /** * 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 {string} dockerfilePath - Path to the Dockerfile. * @param {DockerBuildOptions} opts - Build options. * @returns {Promise<void>} Resolves when build completes successfully. */ async buildWithBuildx(dockerfilePath, opts) { const args = [ // 'buildx', 'build', '-f', dockerfilePath, '--platform', opts.platform, ]; if (opts.push) { args.push('--push'); } else { logger_1.logger.info(`Using --load to import the image into the local Docker daemon (use --push to publish to registry instead)`); args.push('--load'); } if (opts.noCache) { args.push('--no-cache'); } args.push('-t', opts.image, opts.dir); logger_1.logger.debug(`arguments passed to docker ${args.join(' ')}`); if (opts.push) { await this.execWithRetry(args); } else { await this.exec(args); } } /** * Build using legacy docker build (fallback when buildx is not available). * @param {string} dockerfilePath - Path to the Dockerfile. * @param {DockerBuildOptions} opts - Build options. * @returns {Promise<void>} Resolves when build completes successfully. */ async buildLegacy(dockerfilePath, opts) { 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 = [ // 'build', '-f', dockerfilePath, '--platform', opts.platform, ]; if (opts.noCache) { args.push('--no-cache'); } args.push('-t', opts.image, opts.dir); logger_1.logger.debug(`arguments passed to docker ${args.join(' ')}`); await this.exec(args); if (opts.push) { await this.push(opts.image); } } /** * 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 {string | undefined} profileName - (Optional) Profile name for credential resolution. * @returns {Promise<void>} Resolves on success, rejects with the final failure otherwise. */ async execWithRetry(args, profileName) { var _a; for (let attempt = 1;; attempt++) { try { await this.execCaptured(args, profileName); 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 {string | undefined} profileName - (Optional) Profile name for credential resolution. * @returns {Promise<void>} Resolves on exit code 0, rejects with DockerExecError otherwise. */ execCaptured(args, profileName) { 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(profileName !== null && profileName !== void 0 ? profileName : exportableProfileName(this.profile)), }); 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 {string | undefined} profileName - (Optional) Profile name for credential resolution. * @returns {Promise<void>} Resolves when command completes successfully. */ exec(args, profileName) { const proc = this.deps.spawn('docker', args, { stdio: ['inherit', process.stderr, process.stderr], env: buildCplnEnv(profileName !== null && profileName !== void 0 ? profileName : exportableProfileName(this.profile)), }); 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