UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

395 lines 16.4 kB
"use strict"; 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