UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

195 lines 8.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RemoteBuildWatch = void 0; const time_1 = require("../../util/time"); const errors_1 = require("./errors"); const config_1 = require("./config"); // ANCHOR - Constants const IN_PROGRESS_STATUSES = new Set(['queued', 'building']); // ANCHOR - RemoteBuildWatch /** * Follows a submitted build to its terminal state: polls the record, delivers * events exactly once, streams the BuildKit log through an opaque cursor, and * classifies failures. An unknown status is treated as still in progress — a * newer service must never fail a healthy build on an older CLI. This class is * the seam a push-based log transport replaces later. */ class RemoteBuildWatch { constructor(service, events, deps = {}) { var _a, _b; this.service = service; this.events = events; this.sleep = (_a = deps.sleep) !== null && _a !== void 0 ? _a : time_1.sleep; this.now = (_b = deps.now) !== null && _b !== void 0 ? _b : Date.now; } // Public Methods // /** * Polls the build until it is pushed; throws a classified failure otherwise. * The deadline still allows one final poll, so a build that finishes during * the last sleep is reported as the success it is. * * @param {WatchTarget} target - The build id and its display reference. * @returns {Promise<BuildRecord>} The pushed build record. */ async untilDone(target) { let warnedUnknown = false; let lastStatus = ''; const deadline = this.now() + config_1.BUILD_TIMEOUT_MS; const delivered = new Set(); const failures = { consecutive: 0, missing: 0 }; const log = { supported: true, printed: false, truncatedReported: false, eofSeen: false, cursor: 0, missing: 0 }; while (true) { let build; try { build = await this.service.getBuild(target.id); failures.consecutive = 0; failures.missing = 0; } catch (e) { failures.consecutive += 1; this.classifyPollFailure(e, failures, target); } if (build) { if (build.status !== lastStatus) { lastStatus = build.status; this.events.onBuildStatus(build.status); } this.deliverEvents(build, delivered); await this.followLog(target.id, log, deadline); if (build.status === 'pushed') { await this.drainLog(target.id, log); return build; } if (build.status === 'failed') { await this.drainLog(target.id, log); throw (0, errors_1.buildFailure)(build, target.org, log.printed); } if (!IN_PROGRESS_STATUSES.has(build.status) && !warnedUnknown) { warnedUnknown = true; this.events.onUnknownStatus(build.status); } } if (this.now() > deadline) { throw new errors_1.RemoteBuildError('timeout', `timed out after ${config_1.BUILD_TIMEOUT_MS / 60000} minutes waiting for the build`, `It may still finish. Check with "cpln image get ${target.displayRef}".`); } await this.sleep(failures.consecutive > 0 ? Math.min(config_1.POLL_INTERVAL_MS * failures.consecutive, config_1.POLL_BACKOFF_MAX_MS) : config_1.POLL_INTERVAL_MS); } } // Private Methods // /** * Delivers the record's events that have not been delivered yet, by server id. * * @param {BuildRecord} build - The current build record. * @param {Set<string>} delivered - The ids already delivered. * @returns {void} */ deliverEvents(build, delivered) { var _a; for (const event of (_a = build.events) !== null && _a !== void 0 ? _a : []) { if (!delivered.has(event.id)) { delivered.add(event.id); this.events.onBuildEvent(event); } } } /** * Pulls whatever the log has past the cursor, until the deadline passes. A * persistently missing endpoint disables the follow quietly — older services * simply have no live log, while a lagging one answers within a few polls. * Transient failures retry on the next poll with the cursor unchanged. * * @param {string} id - The build id. * @param {LogFollowState} log - The cursor and one-time flags, advanced in place. * @param {number} deadline - The epoch millisecond the follow must not run past. * @returns {Promise<void>} */ async followLog(id, log, deadline) { if (!log.supported) { return; } for (let fetches = 0; fetches < config_1.LOG_FETCHES_PER_POLL && this.now() <= deadline; fetches++) { let slice; try { slice = await this.service.getBuildLog(id, log.cursor); } catch (e) { const status = (0, errors_1.httpStatus)(e); // The log store lags a fresh build, so only a repeated miss means there is no endpoint. if (status === 404 || status === 400) { log.missing += 1; log.supported = log.missing <= config_1.LOG_MISSING_TOLERANCE; } return; } log.missing = 0; if (slice.truncated && !log.truncatedReported) { log.truncatedReported = true; this.events.onLogTruncated(); } // Only output the renderer actually showed counts as a log the user can read. if (slice.chunk !== '' && this.events.onBuildLog(slice.chunk)) { log.printed = true; } const advanced = slice.nextOffset !== log.cursor; log.cursor = slice.nextOffset; if (slice.eof) { log.eofSeen = true; return; } if (!advanced) { return; } } } /** * Drains the remaining log after a terminal status, since the log store can lag * the status store. Bounded rounds within a wall-clock budget keep a lagging or * never-eof service from delaying the terminal result indefinitely. * * @param {string} id - The build id. * @param {LogFollowState} log - The cursor and one-time flags, advanced in place. * @returns {Promise<void>} */ async drainLog(id, log) { const deadline = this.now() + config_1.LOG_DRAIN_BUDGET_MS; for (let round = 0; round < config_1.LOG_DRAIN_ROUNDS; round++) { // Nothing left to drain, no endpoint to drain from, or the budget is spent if (!log.supported || log.eofSeen || this.now() > deadline) { return; } const before = log.cursor; await this.followLog(id, log, deadline); if (!log.eofSeen && log.cursor === before) { await this.sleep(config_1.LOG_DRAIN_WAIT_MS); } } } /** * Classifies one failed record poll: an auth failure cannot heal and fails fast, * a build that stays missing past the read-after-write lag fails as gone, and * anything else tolerates a bounded streak. * * @param {unknown} e - The thrown poll error. * @param {PollFailureState} failures - The consecutive-failure counts including this one. * @param {WatchTarget} target - The build id and its display reference. * @returns {void} */ classifyPollFailure(e, failures, target) { const status = (0, errors_1.httpStatus)(e); if (status === 401 || status === 403) { throw (0, errors_1.requestFailure)(e, 'watch the build', target.org); } // A build reads as missing until the record store catches up with the submission. if (status === 404) { failures.missing += 1; if (failures.missing > config_1.POLL_MISSING_TOLERANCE) { throw new errors_1.RemoteBuildError('request-failed', `build ${target.id} no longer exists on the build service`, 'Start a new build.'); } } if (failures.consecutive > config_1.POLL_MAX_FAILURES) { throw new errors_1.RemoteBuildError('unreachable', `lost contact with the build service while waiting for the build (${(0, errors_1.describeRequestError)(e)})`, `It may still finish. Check with "cpln image get ${target.displayRef}".`); } } } exports.RemoteBuildWatch = RemoteBuildWatch; //# sourceMappingURL=watch.js.map