@controlplane/cli
Version:
Control Plane Corporation CLI
338 lines • 13.9 kB
JavaScript
;
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