UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

189 lines 10.7 kB
"use strict"; // 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