@controlplane/cli
Version:
Control Plane Corporation CLI
189 lines • 10.7 kB
JavaScript
;
// ANCHOR - Constants
Object.defineProperty(exports, "__esModule", { value: true });
exports.REGISTRY_ERROR_RULES = exports.DockerExecError = exports.STDERR_TAIL_LIMIT = exports.RETRY_BASE_DELAY_MS = exports.MAX_TRANSIENT_ATTEMPTS = void 0;
exports.describeDockerCommand = describeDockerCommand;
exports.appendToStderrTail = appendToStderrTail;
exports.classifyRegistryError = classifyRegistryError;
exports.retryDelayMs = retryDelayMs;
/** Total invocation attempts for a retryable registry failure, including the first one. */
exports.MAX_TRANSIENT_ATTEMPTS = 3;
/** Base delay between attempts; the effective delay grows linearly with the attempt number (5s, 10s). */
exports.RETRY_BASE_DELAY_MS = 5000;
/** Upper bound on the buffered stderr tail kept for error classification. */
exports.STDERR_TAIL_LIMIT = 8 * 1024;
/** Number of trailing non-empty stderr lines classified when no error marker line is present. */
const ERROR_BLOCK_FALLBACK_LINES = 10;
/** Start of the terminal error block emitted by docker and buildx (column 0 only, so per-step '#N ERROR:' lines are skipped; lowercase 'error: ' is the buildx <=0.8 summary spelling). */
const ERROR_LINE_MARKER = /^(ERROR:|error:\s|error during connect|Error response from daemon)/;
/** Informational trailer lines buildx prints after its error block; never useful as an error message. */
const TRAILER_LINE = /^(View build details:|\s*\d+ warnings? found)/;
// ANCHOR - Error
/** Failure of a docker subprocess, carrying the stderr tail for classification and a readable message. */
class DockerExecError extends Error {
constructor(init) {
super(deriveErrorMessage(init));
this.name = 'DockerExecError';
this.command = init.command;
this.stderrTail = init.stderrTail;
this.exitCode = init.exitCode;
this.signal = init.signal;
}
}
exports.DockerExecError = DockerExecError;
// ANCHOR - Rules
/**
* Ordered classification rules: first match wins, fatal rules lead so a permanent cause
* vetoes transient-looking text near it, and specific rules precede the general ones
* they override; an unmatched error block is fatal (fail fast).
*/
exports.REGISTRY_ERROR_RULES = [
// Fatal: docker daemon not reachable at all
{ reason: 'docker daemon is not reachable', pattern: /Cannot connect to the Docker daemon/i, verdict: 'fatal' },
{ reason: 'docker daemon is not reachable', pattern: /Is the docker daemon running/i, verdict: 'fatal' },
{ reason: 'docker daemon is not reachable', pattern: /error during connect/i, verdict: 'fatal' },
{ reason: 'docker daemon is not reachable', pattern: /failed to connect to the docker API/i, verdict: 'fatal' },
// Token expiry mid-transfer is the one transient in the auth family; a retry fetches a fresh token
{ reason: 'registry token expired mid-transfer', pattern: /unauthorized: authentication required/i, verdict: 'retryable', attemptCap: 2 },
// Fatal: authentication and authorization
{ reason: 'not authorized', pattern: /unauthorized/i, verdict: 'fatal' },
{ reason: 'not authorized', pattern: /authentication required/i, verdict: 'fatal' },
{ reason: 'access denied', pattern: /requested access to the resource is denied/i, verdict: 'fatal' },
{ reason: 'token lacks the required scope', pattern: /insufficient_scope/i, verdict: 'fatal' },
// Fatal: rate limiting recovers on a minutes scale, not within an in-command loop
{ reason: 'rate limited by the registry', pattern: /toomanyrequests|too many requests/i, verdict: 'fatal' },
// Fatal: TLS trust and scheme misconfiguration
{ reason: 'TLS trust failure', pattern: /x509:/i, verdict: 'fatal' },
{ reason: 'registry answered HTTP on an HTTPS endpoint', pattern: /server gave HTTP response to HTTPS client/i, verdict: 'fatal' },
// Fatal: deterministic registry and input errors
{ reason: 'invalid or unknown manifest', pattern: /manifest (invalid|unknown)/i, verdict: 'fatal' },
{ reason: 'unknown or invalid repository name', pattern: /name (unknown|invalid)/i, verdict: 'fatal' },
{ reason: 'repository does not exist', pattern: /repository does not exist/i, verdict: 'fatal' },
{ reason: 'digest mismatch', pattern: /digest invalid/i, verdict: 'fatal' },
{ reason: 'invalid image reference', pattern: /invalid reference format/i, verdict: 'fatal' },
{ reason: 'local disk is full', pattern: /no space left on device/i, verdict: 'fatal' },
{ reason: 'registry hostname does not resolve', pattern: /no such host/i, verdict: 'fatal' },
// Retryable: connection-level failures
{ reason: 'connection reset by peer', pattern: /connection reset by peer/i, verdict: 'retryable' },
{ reason: 'connection closed by the remote host', pattern: /forcibly closed by the remote host/i, verdict: 'retryable' },
{ reason: 'broken pipe', pattern: /broken pipe/i, verdict: 'retryable' },
{ reason: 'connection closed unexpectedly', pattern: /unexpected EOF/, verdict: 'retryable' },
{ reason: 'connection closed unexpectedly', pattern: /use of closed network connection/i, verdict: 'retryable' },
{ reason: 'idle connection closed by the server', pattern: /server closed idle connection/i, verdict: 'retryable' },
{ reason: 'connection recycled by the server', pattern: /http2: server sent GOAWAY/i, verdict: 'retryable' },
// Proxy failures precede the timeout rules so a proxy timeout keeps the proxy attempt cap
{ reason: 'proxy connection failure', pattern: /proxyconnect/i, verdict: 'retryable', attemptCap: 2 },
// Retryable: timeouts
{ reason: 'network timeout', pattern: /i\/o timeout/i, verdict: 'retryable' },
{ reason: 'TLS handshake timeout', pattern: /TLS handshake timeout/i, verdict: 'retryable' },
{ reason: 'request timeout', pattern: /Client\.Timeout exceeded/, verdict: 'retryable' },
{ reason: 'request timeout', pattern: /Client\.Timeout or context cancellation while reading body/, verdict: 'retryable' },
// Retryable: proxy and registry 5xx responses; the status must follow a colon or 'code' so URL digits never match
{
reason: 'transient response from registry or proxy',
pattern: /received unexpected HTTP status:\s*(408|5\d\d)\b/i,
verdict: 'retryable',
},
{
reason: 'transient response from registry or proxy',
pattern: /unexpected status(?: code)?(?:[^\n]*:)?\s*(408|5\d\d)\b/i,
verdict: 'retryable',
},
{
reason: 'transient response from registry or proxy',
pattern: /\b(bad gateway|service unavailable|gateway time-?out)\b/i,
verdict: 'retryable',
},
// Retryable with a lower cap: sometimes transient, sometimes a dead endpoint
{ reason: 'transient DNS failure', pattern: /Temporary failure in name resolution/i, verdict: 'retryable', attemptCap: 2 },
{ reason: 'connection refused', pattern: /connection refused/i, verdict: 'retryable', attemptCap: 2 },
{ reason: 'connection closed without a response', pattern: /(^|:\s*)EOF\s*$/m, verdict: 'retryable', attemptCap: 2 },
];
// SECTION - Functions
/**
* Builds a label for a docker invocation, e.g. 'docker push' or 'docker buildx build'.
* @param {string[]} args - The arguments passed to the docker binary.
* @returns {string} The command label.
*/
function describeDockerCommand(args) {
const verbLength = args[0] === 'buildx' ? 2 : 1;
return ['docker', ...args.slice(0, verbLength)].join(' ');
}
/**
* Appends a stderr chunk to the tail buffer, keeping only the last STDERR_TAIL_LIMIT characters.
* @param {string} tail - The current tail buffer.
* @param {string} chunk - The newly received stderr chunk.
* @returns {string} The updated, bounded tail buffer.
*/
function appendToStderrTail(tail, chunk) {
return (tail + chunk).slice(-exports.STDERR_TAIL_LIMIT);
}
/**
* Classifies a docker failure from its stderr tail as retryable or fatal. Only the
* trailing error block is inspected, so build-step log chatter never sways the verdict.
* @param {string} stderrTail - The bounded stderr tail captured from the subprocess.
* @returns {RegistryErrorClassification} The verdict, matched reason, and optional attempt cap.
*/
function classifyRegistryError(stderrTail) {
const errorBlock = extractErrorBlock(stderrTail);
for (const rule of exports.REGISTRY_ERROR_RULES) {
if (rule.pattern.test(errorBlock)) {
return { verdict: rule.verdict, reason: rule.reason, attemptCap: rule.attemptCap };
}
}
return { verdict: 'fatal', reason: 'unrecognized error' };
}
/**
* Returns the delay before the next attempt, growing linearly with the attempt number.
* @param {number} attempt - The 1-based number of the attempt that just failed.
* @returns {number} The delay in milliseconds.
*/
function retryDelayMs(attempt) {
return exports.RETRY_BASE_DELAY_MS * attempt;
}
// !SECTION
// SECTION - Helpers
/**
* Derives a readable error message: the terminal error line when one exists,
* otherwise a generic exit or signal description.
* @param {DockerExecErrorInit} init - The failure details.
* @returns {string} The message for the thrown error.
*/
function deriveErrorMessage(init) {
if (init.signal) {
return `${init.command} terminated by signal ${init.signal}`;
}
const errorBlock = extractErrorBlock(init.stderrTail);
const candidates = errorBlock
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !TRAILER_LINE.test(line));
const markerLine = candidates.find((line) => ERROR_LINE_MARKER.test(line));
const message = markerLine !== null && markerLine !== void 0 ? markerLine : candidates[candidates.length - 1];
if (message) {
return message;
}
return `${init.command} exited with code ${init.exitCode}`;
}
/**
* Extracts the trailing error block: everything from the last error marker line to the
* end, or the last few non-empty lines when no marker is present.
* @param {string} stderrTail - The bounded stderr tail captured from the subprocess.
* @returns {string} The portion of the tail that describes the terminal failure.
*/
function extractErrorBlock(stderrTail) {
const lines = stderrTail.split('\n');
let markerIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (ERROR_LINE_MARKER.test(lines[i])) {
markerIndex = i;
}
}
if (markerIndex >= 0) {
return lines.slice(markerIndex).join('\n');
}
const nonEmpty = lines.filter((line) => line.trim().length > 0);
return nonEmpty.slice(-ERROR_BLOCK_FALLBACK_LINES).join('\n');
}
// !SECTION
//# sourceMappingURL=retry.js.map