1064 lines
39 KiB
JavaScript
1064 lines
39 KiB
JavaScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import fsPromises from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { collectDistInventory } from "./package-dist.mjs";
|
|
|
|
const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
const SAFE_HOST = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\])(?::[0-9]{1,5})?$/;
|
|
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
const REQUIRED_ENV = [
|
|
"PRODUCTION_FTP_HOST",
|
|
"PRODUCTION_FTP_USER",
|
|
"PRODUCTION_FTP_PASSWORD",
|
|
"PRODUCTION_FTP_PATH",
|
|
"PRODUCTION_CPANEL_USER",
|
|
"PRODUCTION_CPANEL_API_TOKEN",
|
|
"PRODUCTION_CPANEL_API_URL",
|
|
"PRODUCTION_CPANEL_PATH",
|
|
"RELEASE_ARCHIVE_PATH",
|
|
"RELEASE_INVENTORY_PATH",
|
|
"RELEASE_EXPECTED_COMMIT",
|
|
"RELEASE_EXPECTED_BUILD_ID",
|
|
];
|
|
const ROLLBACK_REQUIRED_ENV = [
|
|
"PRODUCTION_CPANEL_USER",
|
|
"PRODUCTION_CPANEL_API_TOKEN",
|
|
"PRODUCTION_CPANEL_API_URL",
|
|
"PRODUCTION_CPANEL_PATH",
|
|
];
|
|
|
|
export class DeploymentError extends Error {
|
|
constructor(message, options) {
|
|
super(message, options);
|
|
this.name = "DeploymentError";
|
|
}
|
|
}
|
|
|
|
function hasControlCharacters(value) {
|
|
return Array.from(String(value)).some((character) => {
|
|
const code = character.charCodeAt(0);
|
|
return code <= 31 || code === 127;
|
|
});
|
|
}
|
|
|
|
function requireString(env, name) {
|
|
const value = env[name];
|
|
if (typeof value !== "string" || value.length === 0) {
|
|
throw new DeploymentError(`Missing required environment variable ${name}.`);
|
|
}
|
|
if (hasControlCharacters(value)) {
|
|
throw new DeploymentError(`${name} contains unsupported control characters.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function validateReleaseId(value, label = "release ID") {
|
|
if (typeof value !== "string" || value.length > 180 || !SAFE_COMPONENT.test(value)) {
|
|
throw new DeploymentError(`${label} must be a safe filename component.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function normalizeRemoteRoot(value, name = "remote path") {
|
|
if (typeof value !== "string" || !value || value === "/") {
|
|
throw new DeploymentError(`${name} must identify a dedicated deployment directory.`);
|
|
}
|
|
if (/[\\,]/.test(value) || hasControlCharacters(value)) {
|
|
throw new DeploymentError(`${name} contains unsupported characters.`);
|
|
}
|
|
|
|
const components = value.split("/").filter(Boolean);
|
|
if (components.length === 0 || components.some((component) => !SAFE_COMPONENT.test(component))) {
|
|
throw new DeploymentError(`${name} must contain only safe path components.`);
|
|
}
|
|
return components.join("/");
|
|
}
|
|
|
|
function normalizeFtpRoot(value) {
|
|
if (value === "/") {
|
|
return "/";
|
|
}
|
|
const normalized = normalizeRemoteRoot(value, "PRODUCTION_FTP_PATH");
|
|
return value.startsWith("/") ? `/${normalized}` : normalized;
|
|
}
|
|
|
|
export function deriveCpanelRoot(cpanelPath, cpanelUser) {
|
|
const normalized = normalizeRemoteRoot(cpanelPath, "PRODUCTION_CPANEL_PATH");
|
|
const homePrefix = `home/${cpanelUser}/`;
|
|
if (normalized.startsWith(homePrefix)) {
|
|
const relative = normalized.slice(homePrefix.length);
|
|
return normalizeRemoteRoot(relative);
|
|
}
|
|
if (normalized === `home/${cpanelUser}`) {
|
|
throw new DeploymentError("PRODUCTION_CPANEL_PATH may not be the cPanel account home directory.");
|
|
}
|
|
if (normalized.startsWith("home/")) {
|
|
throw new DeploymentError("PRODUCTION_CPANEL_PATH is not inside the configured cPanel account home.");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function containedRemotePath(root, ...components) {
|
|
const normalizedRoot = normalizeRemoteRoot(root);
|
|
const normalizedComponents = components.flatMap((component) => String(component).split("/"));
|
|
for (const component of normalizedComponents) {
|
|
validateReleaseId(component, "remote path component");
|
|
}
|
|
const result = [normalizedRoot, ...normalizedComponents].join("/");
|
|
if (result !== normalizedRoot && !result.startsWith(`${normalizedRoot}/`)) {
|
|
throw new DeploymentError("Remote path escaped the deployment root.");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function validateHttpsUrl(value, name) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(value);
|
|
} catch {
|
|
throw new DeploymentError(`${name} must be a valid HTTPS URL.`);
|
|
}
|
|
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
throw new DeploymentError(`${name} must be a credential-free HTTPS URL.`);
|
|
}
|
|
return parsed.href.endsWith("/") ? parsed.href : `${parsed.href}/`;
|
|
}
|
|
|
|
function parseRetentionCount(value) {
|
|
if (value === undefined || value === "") {
|
|
return 5;
|
|
}
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (!Number.isSafeInteger(parsed) || parsed < 2 || parsed > 25 || String(parsed) !== value) {
|
|
throw new DeploymentError("RELEASE_RETAIN_COUNT must be an integer between 2 and 25.");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function readDeploymentConfig(env = process.env, options = {}) {
|
|
const rollbackOnly = options.rollbackOnly === true;
|
|
for (const name of rollbackOnly ? ROLLBACK_REQUIRED_ENV : REQUIRED_ENV) {
|
|
requireString(env, name);
|
|
}
|
|
|
|
const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER");
|
|
if (!SAFE_COMPONENT.test(cpanelUser)) {
|
|
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
|
|
}
|
|
const cpanel = {
|
|
user: cpanelUser,
|
|
token: requireString(env, "PRODUCTION_CPANEL_API_TOKEN"),
|
|
apiUrl: validateHttpsUrl(requireString(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
|
|
root: deriveCpanelRoot(requireString(env, "PRODUCTION_CPANEL_PATH"), cpanelUser),
|
|
};
|
|
if (rollbackOnly) {
|
|
return {
|
|
ftp: { host: "", user: "", password: "", root: "" },
|
|
cpanel,
|
|
};
|
|
}
|
|
|
|
const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH;
|
|
if (!checksumPath) {
|
|
throw new DeploymentError("Missing required environment variable RELEASE_ARCHIVE_SHA256_PATH.");
|
|
}
|
|
if (hasControlCharacters(checksumPath)) {
|
|
throw new DeploymentError("RELEASE_ARCHIVE_SHA256_PATH contains unsupported control characters.");
|
|
}
|
|
|
|
const host = requireString(env, "PRODUCTION_FTP_HOST");
|
|
if (!SAFE_HOST.test(host) || host.includes("..")) {
|
|
throw new DeploymentError("PRODUCTION_FTP_HOST must be a hostname with an optional port.");
|
|
}
|
|
const expectedCommit = requireString(env, "RELEASE_EXPECTED_COMMIT");
|
|
if (!/^[a-f0-9]{40}$/i.test(expectedCommit)) {
|
|
throw new DeploymentError("RELEASE_EXPECTED_COMMIT must be a full Git commit hash.");
|
|
}
|
|
const expectedBuildId = requireString(env, "RELEASE_EXPECTED_BUILD_ID");
|
|
validateReleaseId(expectedBuildId, "RELEASE_EXPECTED_BUILD_ID");
|
|
const releaseId = validateReleaseId(
|
|
env.RELEASE_ID || `${expectedCommit.toLowerCase()}-${expectedBuildId}`,
|
|
"RELEASE_ID"
|
|
);
|
|
|
|
const frontendUrl = env.PRODUCTION_FRONTEND_URL || env.RELEASE_BASE_URL || env.PLAYWRIGHT_BASE_URL;
|
|
if (!frontendUrl) {
|
|
throw new DeploymentError("Set PRODUCTION_FRONTEND_URL, RELEASE_BASE_URL, or PLAYWRIGHT_BASE_URL.");
|
|
}
|
|
|
|
const ftpPath = requireString(env, "PRODUCTION_FTP_PATH");
|
|
const githubRepository = env.RELEASE_GITHUB_REPOSITORY || "";
|
|
const githubToken = env.RELEASE_GITHUB_TOKEN || "";
|
|
let github = null;
|
|
if (githubRepository || githubToken) {
|
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(githubRepository)) {
|
|
throw new DeploymentError("RELEASE_GITHUB_REPOSITORY must use the owner/repository format.");
|
|
}
|
|
if (!githubToken || hasControlCharacters(githubToken)) {
|
|
throw new DeploymentError("RELEASE_GITHUB_TOKEN is missing or contains unsupported characters.");
|
|
}
|
|
github = {
|
|
repository: githubRepository,
|
|
token: githubToken,
|
|
apiUrl: validateHttpsUrl(env.GITHUB_API_URL || "https://api.github.com/", "GITHUB_API_URL"),
|
|
};
|
|
}
|
|
|
|
return {
|
|
ftp: {
|
|
host,
|
|
user: requireString(env, "PRODUCTION_FTP_USER"),
|
|
password: requireString(env, "PRODUCTION_FTP_PASSWORD"),
|
|
root: normalizeFtpRoot(ftpPath),
|
|
},
|
|
cpanel,
|
|
archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")),
|
|
checksumPath: path.resolve(checksumPath),
|
|
inventoryPath: path.resolve(requireString(env, "RELEASE_INVENTORY_PATH")),
|
|
expectedCommit: expectedCommit.toLowerCase(),
|
|
expectedBuildId,
|
|
releaseId,
|
|
frontendUrl: validateHttpsUrl(frontendUrl, "PRODUCTION_FRONTEND_URL"),
|
|
retainCount: parseRetentionCount(env.RELEASE_RETAIN_COUNT),
|
|
github,
|
|
};
|
|
}
|
|
|
|
function lftpQuote(value) {
|
|
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
|
}
|
|
|
|
export function buildLftpScript(config, commands) {
|
|
const { host, user, password, root } = config.ftp;
|
|
const lines = [
|
|
"set cmd:fail-exit yes",
|
|
"set cmd:interactive no",
|
|
"set ftp:ssl-allow yes",
|
|
"set ftp:ssl-force yes",
|
|
"set ftp:ssl-protect-data yes",
|
|
"set ftp:list-options -a",
|
|
"set ssl:verify-certificate yes",
|
|
"set ssl:check-hostname yes",
|
|
"set net:max-retries 3",
|
|
"set net:timeout 20",
|
|
`open -u ${lftpQuote(user)},${lftpQuote(password)} ${lftpQuote(`ftp://${host}`)}`,
|
|
`cd ${lftpQuote(root)}`,
|
|
...commands,
|
|
"bye",
|
|
];
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
export async function defaultProcessRunner(command, args, options = {}) {
|
|
return await new Promise((resolve, reject) => {
|
|
const inherit = options.inherit === true;
|
|
const child = spawn(command, args, {
|
|
env: options.env || process.env,
|
|
stdio: inherit ? "inherit" : ["pipe", "pipe", "pipe"],
|
|
});
|
|
const output = [];
|
|
const errors = [];
|
|
if (!inherit) {
|
|
child.stdout.on("data", (chunk) => output.push(chunk));
|
|
child.stderr.on("data", (chunk) => errors.push(chunk));
|
|
child.stdin.end(options.input || "");
|
|
}
|
|
child.on("error", (error) => {
|
|
reject(new DeploymentError(`${options.label || command} could not start.`, { cause: error }));
|
|
});
|
|
child.on("close", (code) => {
|
|
if (code !== 0) {
|
|
reject(new DeploymentError(`${options.label || command} failed with exit code ${code}.`));
|
|
return;
|
|
}
|
|
resolve({
|
|
stdout: Buffer.concat(output).toString("utf8"),
|
|
stderr: Buffer.concat(errors).toString("utf8"),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function fileSha256(filePath, fsApi = fs) {
|
|
return await new Promise((resolve, reject) => {
|
|
const hash = crypto.createHash("sha256");
|
|
const stream = fsApi.createReadStream(filePath);
|
|
stream.on("error", reject);
|
|
stream.on("data", (chunk) => hash.update(chunk));
|
|
stream.on("end", () => resolve(hash.digest("hex")));
|
|
});
|
|
}
|
|
|
|
function safeArchiveName(filePath) {
|
|
const name = path.basename(filePath);
|
|
if (!SAFE_COMPONENT.test(name) || !name.endsWith(".zip")) {
|
|
throw new DeploymentError("RELEASE_ARCHIVE_PATH must name a safe .zip file.");
|
|
}
|
|
return name;
|
|
}
|
|
|
|
async function readExpectedChecksum(config, fsApi = fsPromises) {
|
|
let content;
|
|
try {
|
|
content = await fsApi.readFile(config.checksumPath, "utf8");
|
|
} catch (error) {
|
|
throw new DeploymentError("Could not read RELEASE_ARCHIVE_SHA256_PATH.", { cause: error });
|
|
}
|
|
const match = content.trim().match(/^([a-f0-9]{64})(?:\s+\*?([^\s]+))?$/i);
|
|
if (!match) {
|
|
throw new DeploymentError("Release checksum sidecar is not valid SHA-256 output.");
|
|
}
|
|
if (match[2] && path.basename(match[2]) !== safeArchiveName(config.archivePath)) {
|
|
throw new DeploymentError("Release checksum sidecar names a different archive.");
|
|
}
|
|
return match[1].toLowerCase();
|
|
}
|
|
|
|
function validateInventoryPath(relativePath) {
|
|
if (
|
|
typeof relativePath !== "string" ||
|
|
!relativePath.startsWith("dist/") ||
|
|
relativePath.includes("\\") ||
|
|
hasControlCharacters(relativePath) ||
|
|
relativePath.split("/").some((part) => !part || part === "." || part === "..")
|
|
) {
|
|
throw new DeploymentError("Release inventory contains an unsafe path.");
|
|
}
|
|
}
|
|
|
|
async function readExpectedInventory(config, fsApi = fsPromises) {
|
|
let inventory;
|
|
try {
|
|
inventory = JSON.parse(await fsApi.readFile(config.inventoryPath, "utf8"));
|
|
} catch (error) {
|
|
throw new DeploymentError("Could not read a valid RELEASE_INVENTORY_PATH.", { cause: error });
|
|
}
|
|
if (inventory?.schema_version !== 1 || !Array.isArray(inventory.files) || inventory.files.length === 0) {
|
|
throw new DeploymentError("Release inventory does not use the supported schema.");
|
|
}
|
|
let previousPath = "";
|
|
for (const file of inventory.files) {
|
|
validateInventoryPath(file?.path);
|
|
if (
|
|
file.path <= previousPath ||
|
|
!Number.isSafeInteger(file.bytes) ||
|
|
file.bytes < 0 ||
|
|
typeof file.sha256 !== "string" ||
|
|
!SHA256.test(file.sha256)
|
|
) {
|
|
throw new DeploymentError("Release inventory contains invalid or unsorted file metadata.");
|
|
}
|
|
previousPath = file.path;
|
|
}
|
|
return inventory;
|
|
}
|
|
|
|
export function createLftpTransport(config, dependencies = {}) {
|
|
const runner = dependencies.runner || defaultProcessRunner;
|
|
const fsApi = dependencies.fs || fsPromises;
|
|
const streamFs = dependencies.streamFs || fs;
|
|
|
|
async function run(commands) {
|
|
try {
|
|
await runner("lftp", ["-f", "/dev/stdin"], {
|
|
input: buildLftpScript(config, commands),
|
|
label: "FTPS operation",
|
|
});
|
|
} catch (error) {
|
|
throw new DeploymentError("Secure FTPS operation failed.", { cause: error });
|
|
}
|
|
}
|
|
|
|
return {
|
|
async uploadArchive() {
|
|
const archiveName = safeArchiveName(config.archivePath);
|
|
const checksumName = `${archiveName}.sha256`;
|
|
const expectedHash = await readExpectedChecksum(config, fsApi);
|
|
let localHash;
|
|
try {
|
|
localHash = await fileSha256(config.archivePath, streamFs);
|
|
} catch (error) {
|
|
throw new DeploymentError("Could not hash the release archive.", { cause: error });
|
|
}
|
|
if (localHash !== expectedHash) {
|
|
throw new DeploymentError("Release archive does not match its SHA-256 sidecar.");
|
|
}
|
|
|
|
const temporaryDirectory = await fsApi.mkdtemp(path.join(os.tmpdir(), "pleno-ftps-"));
|
|
const downloadedArchive = path.join(temporaryDirectory, archiveName);
|
|
const downloadedChecksum = path.join(temporaryDirectory, checksumName);
|
|
try {
|
|
await run([
|
|
`mkdir -p ${lftpQuote("archives")}`,
|
|
`rm -f ${lftpQuote(`archives/${archiveName}.part`)}`,
|
|
`rm -f ${lftpQuote(`archives/${checksumName}.part`)}`,
|
|
`put ${lftpQuote(config.archivePath)} -o ${lftpQuote(`archives/${archiveName}.part`)}`,
|
|
`put ${lftpQuote(config.checksumPath)} -o ${lftpQuote(`archives/${checksumName}.part`)}`,
|
|
`get ${lftpQuote(`archives/${archiveName}.part`)} -o ${lftpQuote(downloadedArchive)}`,
|
|
`get ${lftpQuote(`archives/${checksumName}.part`)} -o ${lftpQuote(downloadedChecksum)}`,
|
|
]);
|
|
|
|
const remoteHash = await fileSha256(downloadedArchive, streamFs);
|
|
const remoteChecksum = await fsApi.readFile(downloadedChecksum, "utf8");
|
|
if (remoteHash !== expectedHash || remoteChecksum !== (await fsApi.readFile(config.checksumPath, "utf8"))) {
|
|
throw new DeploymentError("Remote release archive verification failed.");
|
|
}
|
|
|
|
await run([
|
|
`mv ${lftpQuote(`archives/${checksumName}.part`)} ${lftpQuote(`archives/${checksumName}`)}`,
|
|
`mv ${lftpQuote(`archives/${archiveName}.part`)} ${lftpQuote(`archives/${archiveName}`)}`,
|
|
]);
|
|
return {
|
|
archiveName,
|
|
checksumName,
|
|
sha256: expectedHash,
|
|
};
|
|
} catch (error) {
|
|
try {
|
|
await run([
|
|
`rm -f ${lftpQuote(`archives/${archiveName}.part`)}`,
|
|
`rm -f ${lftpQuote(`archives/${checksumName}.part`)}`,
|
|
]);
|
|
} catch {
|
|
// The original error is more actionable; stale .part files are safe and overwritten on retry.
|
|
}
|
|
throw error;
|
|
} finally {
|
|
await fsApi.rm(temporaryDirectory, { recursive: true, force: true });
|
|
}
|
|
},
|
|
|
|
async verifyRelease(target) {
|
|
const safeTarget = validateReleaseTarget(target);
|
|
const expectedInventory = await readExpectedInventory(config, fsApi);
|
|
const temporaryDirectory = await fsApi.mkdtemp(path.join(os.tmpdir(), "pleno-release-verify-"));
|
|
const distDirectory = path.join(temporaryDirectory, "dist");
|
|
try {
|
|
await run([`mirror --verbose=0 --parallel=4 ${lftpQuote(safeTarget)} ${lftpQuote(distDirectory)}`]);
|
|
let actualInventory;
|
|
try {
|
|
actualInventory = await collectDistInventory(distDirectory);
|
|
} catch (error) {
|
|
throw new DeploymentError("Could not inventory the extracted cPanel release.", {
|
|
cause: error,
|
|
});
|
|
}
|
|
if (JSON.stringify(actualInventory) !== JSON.stringify(expectedInventory)) {
|
|
throw new DeploymentError("Extracted cPanel release does not match the validated inventory.");
|
|
}
|
|
return actualInventory;
|
|
} finally {
|
|
await fsApi.rm(temporaryDirectory, { recursive: true, force: true });
|
|
}
|
|
},
|
|
|
|
async removeRelease(releaseId) {
|
|
const safeReleaseId = validateReleaseId(releaseId, "release retention ID");
|
|
await run([`rm -r -f ${lftpQuote(`releases/${safeReleaseId}`)}`]);
|
|
},
|
|
|
|
async removeArchives(releaseIds) {
|
|
const safeReleaseIds = Array.from(
|
|
new Set(releaseIds.map((releaseId) => validateReleaseId(releaseId, "archive retention ID")))
|
|
);
|
|
if (safeReleaseIds.length === 0) {
|
|
return;
|
|
}
|
|
await run(
|
|
safeReleaseIds.flatMap((releaseId) => [
|
|
`rm -f ${lftpQuote(`archives/pleno-vue-${releaseId}.zip`)}`,
|
|
`rm -f ${lftpQuote(`archives/pleno-vue-${releaseId}.zip.sha256`)}`,
|
|
])
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
function responseMessage(payload) {
|
|
const result = payload?.cpanelresult;
|
|
return (
|
|
result?.error ||
|
|
result?.event?.reason ||
|
|
result?.data?.find?.((item) => item?.err || item?.reason)?.err ||
|
|
result?.data?.find?.((item) => item?.err || item?.reason)?.reason ||
|
|
payload?.result?.errors?.find?.(Boolean) ||
|
|
"unknown server error"
|
|
);
|
|
}
|
|
|
|
export class CpanelFilemanClient {
|
|
constructor(config, options = {}) {
|
|
this.config = config;
|
|
this.root = normalizeRemoteRoot(config.cpanel.root);
|
|
this.fetch = options.fetchImpl || globalThis.fetch;
|
|
this.timeoutMs = options.timeoutMs || 30_000;
|
|
}
|
|
|
|
redact(value) {
|
|
let result = String(value || "");
|
|
const secrets = [
|
|
this.config.ftp.host,
|
|
this.config.ftp.user,
|
|
this.config.ftp.password,
|
|
this.config.ftp.root,
|
|
this.config.cpanel.root,
|
|
this.config.cpanel.user,
|
|
this.config.cpanel.token,
|
|
].filter(Boolean);
|
|
for (const secret of secrets) {
|
|
result = result.replaceAll(secret, "[redacted]");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
assertContained(remotePath) {
|
|
const normalized = String(remotePath).replace(/^\/+/, "");
|
|
if (normalized !== this.root && !normalized.startsWith(`${this.root}/`)) {
|
|
throw new DeploymentError("cPanel operation escaped the deployment root.");
|
|
}
|
|
if (
|
|
/[\\,]/.test(normalized) ||
|
|
hasControlCharacters(normalized) ||
|
|
normalized.split("/").some((part) => !SAFE_COMPONENT.test(part))
|
|
) {
|
|
throw new DeploymentError("cPanel operation used an unsafe path.");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
async request(endpoint, operation) {
|
|
let response;
|
|
try {
|
|
response = await this.fetch(endpoint, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: `cpanel ${this.config.cpanel.user}:${this.config.cpanel.token}`,
|
|
},
|
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
});
|
|
} catch (error) {
|
|
throw new DeploymentError(`cPanel ${operation} request failed.`, { cause: error });
|
|
}
|
|
if (!response.ok) {
|
|
throw new DeploymentError(`cPanel ${operation} returned HTTP ${response.status}.`);
|
|
}
|
|
|
|
try {
|
|
return await response.json();
|
|
} catch (error) {
|
|
throw new DeploymentError(`cPanel ${operation} returned invalid JSON.`, { cause: error });
|
|
}
|
|
}
|
|
|
|
async call(functionName, parameters) {
|
|
const endpoint = new URL("json-api/cpanel", this.config.cpanel.apiUrl);
|
|
endpoint.searchParams.set("cpanel_jsonapi_user", this.config.cpanel.user);
|
|
endpoint.searchParams.set("cpanel_jsonapi_apiversion", "2");
|
|
endpoint.searchParams.set("cpanel_jsonapi_module", "Fileman");
|
|
endpoint.searchParams.set("cpanel_jsonapi_func", functionName);
|
|
for (const [name, value] of Object.entries(parameters)) {
|
|
endpoint.searchParams.set(name, String(value));
|
|
}
|
|
|
|
const payload = await this.request(endpoint, `Fileman ${functionName}`);
|
|
const result = payload?.cpanelresult;
|
|
const failedItem = result?.data?.find?.((item) => item?.result === 0 || item?.result === false);
|
|
if (result?.event?.result !== 1 || failedItem) {
|
|
throw new DeploymentError(`cPanel Fileman ${functionName} failed: ${this.redact(responseMessage(payload))}`);
|
|
}
|
|
if (Array.isArray(result?.data)) {
|
|
return result.data;
|
|
}
|
|
return [...(result?.files || []), ...(result?.dirs || [])];
|
|
}
|
|
|
|
async fileOp(operation, source, destination) {
|
|
const parameters = {
|
|
op: operation,
|
|
sourcefiles: this.assertContained(source),
|
|
doubledecode: 0,
|
|
};
|
|
if (destination !== undefined) {
|
|
parameters.destfiles = this.assertContained(destination);
|
|
}
|
|
return await this.call("fileop", parameters);
|
|
}
|
|
|
|
async list(directory) {
|
|
const endpoint = new URL("execute/Fileman/list_files", this.config.cpanel.apiUrl);
|
|
endpoint.searchParams.set("dir", this.assertContained(directory));
|
|
endpoint.searchParams.set("include_mime", "0");
|
|
endpoint.searchParams.set("include_permissions", "1");
|
|
endpoint.searchParams.set("limit_to_list", "0");
|
|
endpoint.searchParams.set("show_hidden", "1");
|
|
endpoint.searchParams.set("types", "dir|file|link");
|
|
const payload = await this.request(endpoint, "Fileman list_files");
|
|
if (payload?.result?.status !== 1) {
|
|
throw new DeploymentError(`cPanel Fileman list_files failed: ${this.redact(responseMessage(payload))}`);
|
|
}
|
|
if (!Array.isArray(payload.result.data)) {
|
|
throw new DeploymentError("cPanel Fileman list_files returned an unexpected data shape.");
|
|
}
|
|
return payload.result.data;
|
|
}
|
|
|
|
async mkdir(directory) {
|
|
const safePath = this.assertContained(directory);
|
|
return await this.call("mkdir", {
|
|
path: path.posix.dirname(safePath),
|
|
name: path.posix.basename(safePath),
|
|
permissions: "0755",
|
|
});
|
|
}
|
|
|
|
async copy(source, destination) {
|
|
return await this.fileOp("copy", source, destination);
|
|
}
|
|
|
|
async extract(source, destination) {
|
|
return await this.fileOp("extract", source, destination);
|
|
}
|
|
|
|
async link(source, destination) {
|
|
return await this.fileOp("link", source, destination);
|
|
}
|
|
|
|
async rename(source, destination) {
|
|
return await this.fileOp("rename", source, destination);
|
|
}
|
|
|
|
async unlink(source) {
|
|
return await this.fileOp("unlink", source);
|
|
}
|
|
|
|
async remove(source) {
|
|
return await this.fileOp("trash", source);
|
|
}
|
|
}
|
|
|
|
function entryName(entry) {
|
|
return String(entry?.file || entry?.name || entry?.basename || "");
|
|
}
|
|
|
|
function findEntry(entries, name) {
|
|
return entries.find((entry) => entryName(entry) === name);
|
|
}
|
|
|
|
export function targetRelativeToRoot(root, target) {
|
|
const normalizedRoot = normalizeRemoteRoot(root);
|
|
const normalizedTarget = String(target || "")
|
|
.replaceAll("\\", "/")
|
|
.replace(/\/+$/, "");
|
|
const withoutLeading = normalizedTarget.replace(/^\/+/, "");
|
|
if (withoutLeading.startsWith(`${normalizedRoot}/`)) {
|
|
return withoutLeading.slice(normalizedRoot.length + 1);
|
|
}
|
|
const marker = `/${normalizedRoot}/`;
|
|
const index = normalizedTarget.lastIndexOf(marker);
|
|
return index >= 0 ? normalizedTarget.slice(index + marker.length) : "";
|
|
}
|
|
|
|
export function validateReleaseTarget(value) {
|
|
const parts = String(value || "").split("/");
|
|
if (parts.length !== 3 || parts[0] !== "releases" || parts[2] !== "dist") {
|
|
throw new DeploymentError("Release target was not a contained releases/<id>/dist path.");
|
|
}
|
|
validateReleaseId(parts[1], "release target ID");
|
|
return parts.join("/");
|
|
}
|
|
|
|
export async function assertReleaseTargetExists(client, config, target) {
|
|
const safeTarget = validateReleaseTarget(target);
|
|
const releaseId = safeTarget.split("/")[1];
|
|
const releasesRoot = containedRemotePath(config.cpanel.root, "releases");
|
|
if (!findEntry(await client.list(releasesRoot), releaseId)) {
|
|
throw new DeploymentError("Captured rollback release directory does not exist on cPanel.");
|
|
}
|
|
const releaseRoot = containedRemotePath(releasesRoot, releaseId);
|
|
if (!findEntry(await client.list(releaseRoot), "dist")) {
|
|
throw new DeploymentError("Captured rollback release does not contain a dist directory.");
|
|
}
|
|
}
|
|
|
|
async function ensureDirectory(client, root, relativeDirectory) {
|
|
const components = relativeDirectory.split("/");
|
|
let parent = root;
|
|
for (const component of components) {
|
|
const entries = await client.list(parent);
|
|
if (!findEntry(entries, component)) {
|
|
await client.mkdir(containedRemotePath(parent, component));
|
|
}
|
|
parent = containedRemotePath(parent, component);
|
|
}
|
|
}
|
|
|
|
async function assertAbsent(client, parent, name, description) {
|
|
if (findEntry(await client.list(parent), name)) {
|
|
throw new DeploymentError(`${description} already exists; refusing to overwrite immutable data.`);
|
|
}
|
|
}
|
|
|
|
async function assertCurrentLink(client, config) {
|
|
const entries = await client.list(config.cpanel.root);
|
|
const current = findEntry(entries, "current");
|
|
if (!current) {
|
|
throw new DeploymentError("cPanel does not have a current frontend release pointer.");
|
|
}
|
|
if (current.type !== "link") {
|
|
throw new DeploymentError("cPanel current is not a symbolic link.");
|
|
}
|
|
}
|
|
|
|
async function confirmLink(client, config, name) {
|
|
const entry = findEntry(await client.list(config.cpanel.root), name);
|
|
if (!entry) {
|
|
throw new DeploymentError(`cPanel did not create ${name}.`);
|
|
}
|
|
if (entry.type !== "link") {
|
|
throw new DeploymentError(`cPanel ${name} is not a symbolic link.`);
|
|
}
|
|
}
|
|
|
|
export async function capturePublishedReleaseTarget(config, options = {}) {
|
|
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
const manifestUrl = new URL("release-manifest.json", config.frontendUrl);
|
|
let response;
|
|
try {
|
|
response = await fetchImpl(manifestUrl, {
|
|
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
|
signal: AbortSignal.timeout(options.timeoutMs || 30_000),
|
|
});
|
|
} catch (error) {
|
|
throw new DeploymentError("Could not capture the currently published release identity.", {
|
|
cause: error,
|
|
});
|
|
}
|
|
if (!response.ok) {
|
|
throw new DeploymentError(`Current release-manifest.json returned HTTP ${response.status}; refusing deployment.`);
|
|
}
|
|
let manifest;
|
|
try {
|
|
manifest = await response.json();
|
|
} catch (error) {
|
|
throw new DeploymentError("Current release-manifest.json was not valid JSON.", { cause: error });
|
|
}
|
|
const commit = String(manifest?.commit_sha || "").toLowerCase();
|
|
const buildId = String(manifest?.build_id || "");
|
|
if (!/^[a-f0-9]{40}$/.test(commit)) {
|
|
throw new DeploymentError("Current release-manifest.json did not contain a full commit SHA.");
|
|
}
|
|
validateReleaseId(buildId, "current release build ID");
|
|
return validateReleaseTarget(`releases/${commit}-${buildId}/dist`);
|
|
}
|
|
|
|
export async function assertExpectedCommitCurrent(config, options = {}) {
|
|
if (!config.github) {
|
|
throw new DeploymentError("RELEASE_GITHUB_REPOSITORY and RELEASE_GITHUB_TOKEN are required for deployment.");
|
|
}
|
|
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
const [owner, repository] = config.github.repository.split("/");
|
|
const endpoint = new URL(
|
|
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/master`,
|
|
config.github.apiUrl
|
|
);
|
|
let response;
|
|
try {
|
|
response = await fetchImpl(endpoint, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${config.github.token}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
signal: AbortSignal.timeout(options.timeoutMs || 30_000),
|
|
});
|
|
} catch (error) {
|
|
throw new DeploymentError("Could not confirm the current master commit with GitHub.", {
|
|
cause: error,
|
|
});
|
|
}
|
|
if (!response.ok) {
|
|
throw new DeploymentError(`GitHub branch check returned HTTP ${response.status}; refusing deployment.`);
|
|
}
|
|
let payload;
|
|
try {
|
|
payload = await response.json();
|
|
} catch (error) {
|
|
throw new DeploymentError("GitHub branch check returned invalid JSON.", { cause: error });
|
|
}
|
|
const currentCommit = String(payload?.commit?.sha || "").toLowerCase();
|
|
if (currentCommit !== config.expectedCommit) {
|
|
throw new DeploymentError(
|
|
`Release commit is stale; master is ${currentCommit || "unknown"}, expected ${config.expectedCommit}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function atomicSwitch(client, config, target) {
|
|
const safeTarget = validateReleaseTarget(target);
|
|
const root = config.cpanel.root;
|
|
const nextPath = containedRemotePath(root, "current.next");
|
|
const currentPath = containedRemotePath(root, "current");
|
|
const targetPath = containedRemotePath(root, safeTarget);
|
|
const entries = await client.list(root);
|
|
if (findEntry(entries, "current.next")) {
|
|
await client.unlink(nextPath);
|
|
}
|
|
await client.link(targetPath, nextPath);
|
|
await confirmLink(client, config, "current.next");
|
|
await client.rename(nextPath, currentPath);
|
|
await confirmLink(client, config, "current");
|
|
}
|
|
|
|
export async function preflightAtomicSwitch(client, config, options = {}) {
|
|
const root = config.cpanel.root;
|
|
await ensureDirectory(client, root, "staging");
|
|
const probeId = options.probeId || `preflight-${crypto.randomUUID()}`;
|
|
validateReleaseId(probeId, "preflight ID");
|
|
const probe = containedRemotePath(root, "staging", probeId);
|
|
await client.mkdir(probe);
|
|
let operationError;
|
|
try {
|
|
const first = containedRemotePath(probe, "first");
|
|
const second = containedRemotePath(probe, "second");
|
|
await client.mkdir(first);
|
|
await client.mkdir(second);
|
|
await client.mkdir(containedRemotePath(first, "first-marker"));
|
|
await client.mkdir(containedRemotePath(second, "second-marker"));
|
|
await client.link(first, containedRemotePath(probe, "current"));
|
|
await client.link(second, containedRemotePath(probe, "current.next"));
|
|
await client.rename(containedRemotePath(probe, "current.next"), containedRemotePath(probe, "current"));
|
|
const currentEntries = await client.list(probe);
|
|
const current = findEntry(currentEntries, "current");
|
|
if (!current || current.type !== "link") {
|
|
throw new DeploymentError("cPanel atomic replacement preflight did not leave a current link.");
|
|
}
|
|
if (findEntry(currentEntries, "current.next")) {
|
|
throw new DeploymentError("cPanel atomic replacement preflight left current.next behind.");
|
|
}
|
|
const activeEntries = await client.list(containedRemotePath(probe, "current"));
|
|
if (!findEntry(activeEntries, "second-marker") || findEntry(activeEntries, "first-marker")) {
|
|
throw new DeploymentError("cPanel atomic replacement preflight did not activate the new link target.");
|
|
}
|
|
} catch (error) {
|
|
operationError = error;
|
|
}
|
|
|
|
let cleanupError;
|
|
try {
|
|
await client.remove(probe);
|
|
} catch (error) {
|
|
cleanupError = error;
|
|
}
|
|
if (operationError) {
|
|
throw new DeploymentError("cPanel does not support the required atomic symlink replacement.", {
|
|
cause: operationError,
|
|
});
|
|
}
|
|
if (cleanupError) {
|
|
throw new DeploymentError("Could not clean up the cPanel atomicity preflight directory.", {
|
|
cause: cleanupError,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function assertArchiveNamesAvailable(client, config, archiveName, checksumName) {
|
|
const archiveRoot = containedRemotePath(config.cpanel.root, "archives");
|
|
await ensureDirectory(client, config.cpanel.root, "archives");
|
|
const entries = await client.list(archiveRoot);
|
|
if (findEntry(entries, archiveName) || findEntry(entries, checksumName)) {
|
|
throw new DeploymentError("The immutable release archive name already exists on cPanel.");
|
|
}
|
|
}
|
|
|
|
export async function stageRelease(client, config, archiveName) {
|
|
const root = config.cpanel.root;
|
|
await ensureDirectory(client, root, "releases");
|
|
await ensureDirectory(client, root, "staging");
|
|
const stagingRoot = containedRemotePath(root, "staging");
|
|
const releasesRoot = containedRemotePath(root, "releases");
|
|
const stagingName = `${config.releaseId}.pending`;
|
|
await assertAbsent(client, stagingRoot, stagingName, "Release staging directory");
|
|
await assertAbsent(client, releasesRoot, config.releaseId, "Release directory");
|
|
|
|
const stagingPath = containedRemotePath(stagingRoot, stagingName);
|
|
const releasePath = containedRemotePath(releasesRoot, config.releaseId);
|
|
await client.mkdir(stagingPath);
|
|
const sourceArchive = containedRemotePath(root, "archives", archiveName);
|
|
await client.copy(sourceArchive, stagingPath);
|
|
const stagedArchive = containedRemotePath(stagingPath, archiveName);
|
|
await client.extract(stagedArchive, stagingPath);
|
|
await client.remove(stagedArchive);
|
|
|
|
const stagingEntries = await client.list(stagingPath);
|
|
if (!findEntry(stagingEntries, "dist")) {
|
|
throw new DeploymentError("Extracted release did not contain a top-level dist directory.");
|
|
}
|
|
const distPath = containedRemotePath(stagingPath, "dist");
|
|
const distEntries = await client.list(distPath);
|
|
for (const requiredFile of ["index.html", "release-manifest.json", "release-entry.json"]) {
|
|
if (!findEntry(distEntries, requiredFile)) {
|
|
throw new DeploymentError(`Extracted release was missing ${requiredFile}.`);
|
|
}
|
|
}
|
|
|
|
await client.rename(stagingPath, releasePath);
|
|
if (!findEntry(await client.list(releasesRoot), config.releaseId)) {
|
|
throw new DeploymentError("cPanel did not finalize the immutable release directory.");
|
|
}
|
|
return `releases/${config.releaseId}/dist`;
|
|
}
|
|
|
|
export async function runPublicVerification(config, options = {}) {
|
|
const runner = options.runner || defaultProcessRunner;
|
|
const verifier = fileURLToPath(new URL("./verify-upload.mjs", import.meta.url));
|
|
await runner(process.execPath, [verifier], {
|
|
inherit: true,
|
|
label: "public release verification",
|
|
env: {
|
|
...process.env,
|
|
RELEASE_BASE_URL: config.frontendUrl,
|
|
RELEASE_EXPECTED_COMMIT: config.expectedCommit,
|
|
RELEASE_EXPECTED_BUILD_ID: config.expectedBuildId,
|
|
RELEASE_STRICT_BUILD_ID: "1",
|
|
RELEASE_WAIT_INITIAL_SECONDS: process.env.RELEASE_WAIT_INITIAL_SECONDS || "0",
|
|
},
|
|
});
|
|
}
|
|
|
|
function entryMtime(entry) {
|
|
const value = Number(entry?.mtime ?? entry?.modified ?? entry?.mtime_epoch);
|
|
return Number.isFinite(value) && value > 0 ? value : null;
|
|
}
|
|
|
|
export async function pruneInactiveReleases(client, config, protectedTargets, options = {}) {
|
|
const releasesRoot = containedRemotePath(config.cpanel.root, "releases");
|
|
const entries = await client.list(releasesRoot);
|
|
const releases = entries
|
|
.map((entry) => ({ id: entryName(entry), mtime: entryMtime(entry) }))
|
|
.filter(({ id }) => SAFE_COMPONENT.test(id));
|
|
if (releases.some(({ mtime }) => mtime === null)) {
|
|
return [];
|
|
}
|
|
|
|
const protectedIds = new Set(
|
|
protectedTargets.filter(Boolean).map((target) => validateReleaseTarget(target).split("/")[1])
|
|
);
|
|
const sorted = releases.sort((left, right) => right.mtime - left.mtime);
|
|
const keepIds = new Set([...protectedIds, ...sorted.slice(0, config.retainCount).map(({ id }) => id)]);
|
|
const removed = [];
|
|
const removeRelease =
|
|
options.removeRelease || (async (id) => await client.remove(containedRemotePath(releasesRoot, id)));
|
|
for (const { id } of sorted) {
|
|
if (!keepIds.has(id)) {
|
|
await removeRelease(id);
|
|
removed.push(id);
|
|
}
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
export function emitDeploymentOutputs(values, env = process.env, fsApi = fs) {
|
|
const pairs = Object.entries(values).filter(([, value]) => value !== undefined && value !== "");
|
|
if (env.GITHUB_ENV && pairs.length > 0) {
|
|
fsApi.appendFileSync(env.GITHUB_ENV, `${pairs.map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
|
|
}
|
|
if (env.GITHUB_OUTPUT && pairs.length > 0) {
|
|
const outputNames = {
|
|
RELEASE_ROLLBACK_TARGET: "rollback_target",
|
|
RELEASE_ACTIVE_TARGET: "active_target",
|
|
RELEASE_DEPLOYED_RELEASE_ID: "release_id",
|
|
};
|
|
const output = pairs
|
|
.filter(([key]) => outputNames[key])
|
|
.map(([key, value]) => `${outputNames[key]}=${value}`)
|
|
.join("\n");
|
|
if (output) {
|
|
fsApi.appendFileSync(env.GITHUB_OUTPUT, `${output}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function deployRelease(config, dependencies = {}) {
|
|
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
|
|
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
|
const preflight = dependencies.preflight || preflightAtomicSwitch;
|
|
const stage = dependencies.stage || stageRelease;
|
|
const verify = dependencies.verify || runPublicVerification;
|
|
const prune = dependencies.prune || pruneInactiveReleases;
|
|
const publish = dependencies.publish || emitDeploymentOutputs;
|
|
const capturePrevious = dependencies.capturePrevious || capturePublishedReleaseTarget;
|
|
const checkCurrent = dependencies.checkCurrent || assertExpectedCommitCurrent;
|
|
|
|
await checkCurrent(config, dependencies);
|
|
await preflight(client, config);
|
|
await assertCurrentLink(client, config);
|
|
const previousTarget = await capturePrevious(config, dependencies);
|
|
await assertReleaseTargetExists(client, config, previousTarget);
|
|
|
|
const archiveName = safeArchiveName(config.archivePath);
|
|
const checksumName = `${archiveName}.sha256`;
|
|
await assertArchiveNamesAvailable(client, config, archiveName, checksumName);
|
|
const uploaded = await transport.uploadArchive();
|
|
const newTarget = await stage(client, config, uploaded.archiveName);
|
|
await transport.verifyRelease(newTarget);
|
|
await checkCurrent(config, dependencies);
|
|
await atomicSwitch(client, config, newTarget);
|
|
publish({
|
|
RELEASE_ROLLBACK_TARGET: previousTarget,
|
|
RELEASE_ACTIVE_TARGET: newTarget,
|
|
RELEASE_DEPLOYED_RELEASE_ID: config.releaseId,
|
|
});
|
|
|
|
try {
|
|
await verify(config, dependencies);
|
|
} catch (error) {
|
|
try {
|
|
await atomicSwitch(client, config, previousTarget);
|
|
} catch (rollbackError) {
|
|
throw new DeploymentError(
|
|
"Public verification failed and automatic rollback also failed; production requires immediate attention.",
|
|
{ cause: new AggregateError([error, rollbackError]) }
|
|
);
|
|
}
|
|
throw new DeploymentError("Public verification failed; the previous release was restored.", {
|
|
cause: error,
|
|
});
|
|
}
|
|
|
|
await assertCurrentLink(client, config);
|
|
let removed = [];
|
|
let retentionWarning = "";
|
|
try {
|
|
removed = await prune(client, config, [newTarget, previousTarget], {
|
|
removeRelease: async (releaseId) => {
|
|
await transport.removeArchives([releaseId]);
|
|
await transport.removeRelease(releaseId);
|
|
},
|
|
});
|
|
} catch {
|
|
retentionWarning = "Verified deployment succeeded, but old release retention cleanup failed.";
|
|
}
|
|
return { previousTarget, activeTarget: newTarget, removed, retentionWarning };
|
|
}
|
|
|
|
export async function rollbackRelease(config, target, dependencies = {}) {
|
|
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
|
|
const preflight = dependencies.preflight || preflightAtomicSwitch;
|
|
const publish = dependencies.publish || emitDeploymentOutputs;
|
|
const safeTarget = validateReleaseTarget(target);
|
|
await assertReleaseTargetExists(client, config, safeTarget);
|
|
await preflight(client, config);
|
|
await atomicSwitch(client, config, safeTarget);
|
|
publish({
|
|
RELEASE_ACTIVE_TARGET: safeTarget,
|
|
RELEASE_DEPLOYED_RELEASE_ID: safeTarget.split("/")[1],
|
|
});
|
|
return { activeTarget: safeTarget };
|
|
}
|