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