@controlplane/cli
Version:
Control Plane Corporation CLI
395 lines • 16.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.realFileOps = exports.httpDownloader = void 0;
exports.getBaseUrl = getBaseUrl;
exports.resolveBinaryArtifact = resolveBinaryArtifact;
exports.isMuslLinux = isMuslLinux;
exports.installBinaries = installBinaries;
exports.resolveTargets = resolveTargets;
exports.fetchLatestTag = fetchLatestTag;
exports.parseTagEpoch = parseTagEpoch;
exports.performBinaryUpgrade = performBinaryUpgrade;
const fs = require("fs");
const path = require("path");
const axios_1 = require("axios");
const binary_archive_1 = require("./binary-archive");
// ANCHOR - Constants
/** The public base URL of the release-artifact bucket. Overridable for staging/tests. */
const DEFAULT_BASE_URL = 'https://storage.googleapis.com/artifacts.cpln-build.appspot.com/binaries/cpln';
/** The executable bit set granted to a freshly written binary (rwxr-xr-x). */
const EXECUTABLE_MODE = 0o755;
/** Time budget per download; generous enough for the full archive on a slow link. */
const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
/** Upper bound on any single downloaded artifact (500 MB). */
const MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024;
/** Caps the CLI-wide retry policy for release downloads; re-fetching a full archive many times is worse than failing. */
const DOWNLOAD_RETRY_POLICY = { retries: 2 };
// SECTION - Functions
/**
* Returns the release-artifact base URL, honoring the CPLN_UPGRADE_BASE_URL override.
*
* @param {NodeJS.ProcessEnv} env - The process environment.
* @returns {string} The base URL without a trailing slash.
*/
function getBaseUrl(env) {
const override = env.CPLN_UPGRADE_BASE_URL;
const base = override && override.trim() !== '' ? override.trim() : DEFAULT_BASE_URL;
return base.replace(/\/+$/, '');
}
/**
* Selects the release archive for a platform, or null when no standalone binary is published.
*
* The `pkg` build only targets linux-x64, alpine-x64, and windows-x64; macOS is
* delivered exclusively via the notarized DMG and Homebrew, so it has no
* self-replaceable standalone binary.
*
* @param {PlatformInfo} info - The running platform, architecture, and libc flavor.
* @returns {BinaryArtifact | null} The matching artifact, or null when unsupported.
*/
function resolveBinaryArtifact(info) {
if (info.arch !== 'x64') {
return null;
}
if (info.platform === 'win32') {
return {
archive: 'cpln-win.zip',
format: 'zip',
members: [
{ nameInArchive: 'cpln.exe', targetBaseName: 'cpln.exe' },
{ nameInArchive: 'docker-credential-cpln.exe', targetBaseName: 'docker-credential-cpln.exe' },
],
};
}
if (info.platform === 'linux') {
const archive = info.isMusl ? 'cpln-alpine.tgz' : 'cpln-linux.tgz';
return {
archive,
format: 'tgz',
members: [
{ nameInArchive: 'cpln', targetBaseName: 'cpln' },
{ nameInArchive: 'docker-credential-cpln', targetBaseName: 'docker-credential-cpln' },
],
};
}
return null;
}
/**
* Detects whether the running Linux system uses musl libc (Alpine and other musl
* distros), which needs the musl-linked binary rather than the glibc one.
*
* @param {NodeJS.Platform} platform - The running platform.
* @returns {boolean} True when the system links against musl rather than glibc.
*/
function isMuslLinux(platform) {
if (platform !== 'linux') {
return false;
}
// Alpine is the common case and cheap to detect
try {
if (fs.existsSync('/etc/alpine-release')) {
return true;
}
}
catch (_a) {
// Fall through to the libc probe
}
// A glibc runtime reports its version in the process report; musl does not
try {
const reporter = process.report;
const header = reporter === null || reporter === void 0 ? void 0 : reporter.getReport().header;
if (header && !('glibcVersionRuntime' in header)) {
return true;
}
}
catch (_b) {
// Unable to probe; assume glibc
}
return false;
}
/**
* Installs several executables as an all-or-nothing unit.
*
* Every replacement is first staged (new bytes written beside its target), then
* all are committed. If any commit fails, every already-committed binary is
* rolled back to its original, so cpln and its docker-credential helper are never
* left at mismatched versions.
*
* @param {BinaryInstall[]} installs - The executables to replace and their new bytes.
* @param {BinaryFileOps} fileOps - The filesystem operations to use.
* @returns {string[]} The destinations that were replaced.
* @throws {Error} When staging or committing fails (after rolling back).
*/
function installBinaries(installs, fileOps) {
const staged = [];
// Phase 1: stage every new binary without disturbing the current install
try {
for (const install of installs) {
staged.push(stageReplacement(install.destination, install.contents, fileOps));
}
}
catch (error) {
// Nothing is committed yet; only staging temps exist to remove
for (const replacement of staged) {
tryUnlink(replacement.tempPath, fileOps);
}
throw error;
}
// Phase 2: commit every staged binary, rolling all back if any fails
const committed = [];
try {
for (const replacement of staged) {
commitReplacement(replacement, fileOps);
committed.push(replacement);
}
}
catch (error) {
// Only replacements up to and including the failing one ever touched their destination
const attempted = staged.slice(0, committed.length + 1);
for (const replacement of committed) {
rollbackReplacement(replacement, fileOps);
}
// On failure, remove only the staging temps and PRESERVE every backup as a
// recovery copy — a swallowed rollback error must never cost the original
for (const replacement of staged) {
tryUnlink(replacement.tempPath, fileOps);
}
throw withBackupLocations(error, attempted, fileOps);
}
// Success: the backups are now obsolete old versions and safe to discard
for (const replacement of staged) {
tryUnlink(replacement.tempPath, fileOps);
tryUnlink(replacement.backupPath, fileOps);
}
return staged.map((replacement) => replacement.destination);
}
/**
* Appends the surviving recovery-backup locations to a failed install's error, so
* a user whose original binary could not be restored knows where to find it.
*
* @param {unknown} error - The error that aborted the install.
* @param {StagedReplacement[]} attempted - The replacements whose destinations were touched.
* @param {BinaryFileOps} fileOps - The filesystem operations to use for existence checks.
* @returns {unknown} The same error, with backup locations appended when any survive.
*/
function withBackupLocations(error, attempted, fileOps) {
if (!(error instanceof Error)) {
return error;
}
const backups = attempted
.filter((replacement) => replacement.hadOriginal && fileOps.existsSync(replacement.backupPath))
.map((replacement) => replacement.backupPath);
if (backups.length > 0) {
error.message = `${error.message} The previous executable(s) are preserved at: ${backups.join(', ')}`;
}
return error;
}
/**
* Stages a replacement: writes the new bytes to a temp file beside the target and
* marks it executable, without touching the current binary yet.
*
* @param {string} destination - The executable to eventually replace.
* @param {Buffer} contents - The new executable bytes.
* @param {BinaryFileOps} fileOps - The filesystem operations to use.
* @returns {StagedReplacement} The staging bookkeeping.
*/
function stageReplacement(destination, contents, fileOps) {
const directory = path.dirname(destination);
const base = path.basename(destination);
const tempPath = path.join(directory, `.${base}.cpln-upgrade`);
const backupPath = path.join(directory, `.${base}.cpln-old`);
// Staging stays on one volume so the eventual rename is atomic
fileOps.writeFileSync(tempPath, contents);
try {
fileOps.chmodSync(tempPath, EXECUTABLE_MODE);
}
catch (error) {
// The temp was never registered for cleanup, so remove it before aborting
tryUnlink(tempPath, fileOps);
throw error;
}
return { destination, tempPath, backupPath, hadOriginal: fileOps.existsSync(destination), committed: false };
}
/**
* Commits a staged replacement by moving the current binary aside and moving the
* new one into place. Moving aside (rather than overwriting) is what lets a
* running Windows .exe be replaced safely.
*
* @param {StagedReplacement} staged - The staged replacement to commit.
* @param {BinaryFileOps} fileOps - The filesystem operations to use.
* @returns {void}
*/
function commitReplacement(staged, fileOps) {
// Clear any stale backup, then move the current binary aside
tryUnlink(staged.backupPath, fileOps);
if (staged.hadOriginal) {
fileOps.renameSync(staged.destination, staged.backupPath);
}
try {
fileOps.renameSync(staged.tempPath, staged.destination);
}
catch (error) {
// Restore the original if the final move failed
if (staged.hadOriginal) {
fileOps.renameSync(staged.backupPath, staged.destination);
}
throw error;
}
staged.committed = true;
}
/**
* Reverses a committed replacement, restoring the original binary.
*
* @param {StagedReplacement} staged - The replacement to roll back.
* @param {BinaryFileOps} fileOps - The filesystem operations to use.
* @returns {void}
*/
function rollbackReplacement(staged, fileOps) {
if (!staged.committed) {
tryUnlink(staged.tempPath, fileOps);
return;
}
if (staged.hadOriginal) {
// The destination holds the new binary; park it, then restore the original.
// If this fails, the backup is left untouched so it survives as a recovery copy.
try {
fileOps.renameSync(staged.destination, staged.tempPath);
fileOps.renameSync(staged.backupPath, staged.destination);
}
catch (_a) {
// Best-effort restore; the caller preserves every backup on the failure path
}
}
else {
// Nothing was here before, so remove the file this upgrade created
tryUnlink(staged.destination, fileOps);
}
staged.committed = false;
}
/**
* Deletes a file, ignoring any error (e.g., the file is absent or still locked).
*
* @param {string} filePath - The path to delete.
* @param {BinaryFileOps} fileOps - The filesystem operations to use.
* @returns {void}
*/
function tryUnlink(filePath, fileOps) {
try {
if (fileOps.existsSync(filePath)) {
fileOps.unlinkSync(filePath);
}
}
catch (_a) {
// Ignore — cleanup is best-effort
}
}
/**
* Resolves the on-disk destinations for an artifact's members, given the running
* executable path. Only members that already exist beside it are targeted, so the
* optional docker-credential helper is updated only when it is installed.
*
* @param {BinaryArtifact} artifact - The selected release artifact.
* @param {string} execPath - The real path of the running cpln executable.
* @param {BinaryFileOps} fileOps - The filesystem operations to use for existence checks.
* @returns {BinaryTarget[]} The members paired with their destination paths.
*/
function resolveTargets(artifact, execPath, fileOps) {
const directory = path.dirname(execPath);
const targets = [];
for (const member of artifact.members) {
// The running cpln binary is always the primary target, at its exact path
const isPrimary = member.nameInArchive === 'cpln' || member.nameInArchive === 'cpln.exe';
const destination = isPrimary ? execPath : path.join(directory, member.targetBaseName);
// Replace the running binary unconditionally; replace siblings only if present
if (isPrimary || fileOps.existsSync(destination)) {
targets.push({ member, destination });
}
}
return targets;
}
/**
* Reads the `latest` release pointer and returns the tag it names.
*
* @param {string} baseUrl - The release-artifact base URL.
* @param {BinaryDownloader} download - The downloader to fetch with.
* @returns {Promise<string>} The latest release tag.
* @throws {Error} When the pointer is empty.
*/
async function fetchLatestTag(baseUrl, download) {
const body = await download(`${baseUrl}/latest`);
const tag = body.toString('utf-8').split('\n')[0].trim();
if (tag === '') {
throw new Error('The latest release pointer is empty; cannot determine which version to download.');
}
return tag;
}
/**
* Extracts the numeric epoch prefix of a release tag ({epoch}-{gitsha}), used to
* order releases.
*
* @param {string} tag - The release tag.
* @returns {number | undefined} The epoch, or undefined when the tag has no numeric prefix.
*/
function parseTagEpoch(tag) {
const match = tag.match(/^(\d+)-/);
return match ? Number(match[1]) : undefined;
}
/**
* Downloads, verifies, extracts, and installs a standalone binary release.
*
* @param {BinaryUpgradeParams} params - The resolved inputs for the upgrade.
* @returns {Promise<string[]>} The paths of the executables that were replaced.
* @throws {Error} When the checksum is missing/mismatched or a member is absent.
*/
async function performBinaryUpgrade(params) {
const releaseUrl = `${params.baseUrl}/${params.tag}`;
// Fetch and parse the published checksums before trusting any archive bytes
const sumsBody = await params.download(`${releaseUrl}/SHA256SUMS`);
const sums = (0, binary_archive_1.parseSha256Sums)(sumsBody.toString('utf-8'));
// Download the platform archive and verify it against its published digest
const archive = await params.download(`${releaseUrl}/${params.artifact.archive}`);
(0, binary_archive_1.verifyChecksum)(archive, params.artifact.archive, sums);
// Decompress once, then map each installed executable to its new bytes
const targets = resolveTargets(params.artifact, params.execPath, params.fileOps);
const members = await (0, binary_archive_1.extractMembers)(archive, params.artifact, targets.map((target) => target.member.nameInArchive));
const installs = [];
for (const target of targets) {
const bytes = members.get(target.member.nameInArchive);
// An absent or empty member must never replace a working executable
if (!bytes || bytes.length === 0) {
throw new Error(`Archive member '${target.member.nameInArchive}' is missing or empty in the downloaded release.`);
}
installs.push({ destination: target.destination, contents: bytes });
}
// Replace every installed executable as an all-or-nothing unit
return installBinaries(installs, params.fileOps);
}
/**
* The default downloader: fetches a URL as raw bytes via axios, bypassing shared
* caches so a stale `latest` pointer cannot serve an old release.
*
* @param {string} url - The URL to fetch.
* @returns {Promise<Buffer>} The response body as a Buffer.
*/
const httpDownloader = async (url) => {
const response = await axios_1.default.get(url, {
responseType: 'arraybuffer',
timeout: DOWNLOAD_TIMEOUT_MS,
maxContentLength: MAX_DOWNLOAD_BYTES,
headers: { 'Cache-Control': 'no-cache' },
'axios-retry': DOWNLOAD_RETRY_POLICY,
});
return Buffer.from(response.data);
};
exports.httpDownloader = httpDownloader;
/**
* The default filesystem operations, backed by the real fs module.
*/
exports.realFileOps = {
existsSync: (filePath) => fs.existsSync(filePath),
writeFileSync: (filePath, data) => fs.writeFileSync(filePath, data),
chmodSync: (filePath, mode) => fs.chmodSync(filePath, mode),
renameSync: (from, to) => fs.renameSync(from, to),
unlinkSync: (filePath) => fs.unlinkSync(filePath),
};
// !SECTION
//# sourceMappingURL=binary-upgrade.js.map