1048 lines
39 KiB
JavaScript
1048 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 succeeded = (value) => value === 1 || value === "1" || value === true;
|
|
const REQUIRED_ENV = [
|
|
"PRODUCTION_FTP_HOST",
|
|
"PRODUCTION_FTP_USER",
|
|
"PRODUCTION_FTP_PASSWORD",
|
|
"PRODUCTION_FTP_PATH",
|
|
"PRODUCTION_ACTIVATION_KEY",
|
|
"RELEASE_ARCHIVE_PATH",
|
|
"RELEASE_INVENTORY_PATH",
|
|
"RELEASE_EXPECTED_COMMIT",
|
|
"RELEASE_EXPECTED_BUILD_ID",
|
|
];
|
|
const ROLLBACK_REQUIRED_ENV = [
|
|
"PRODUCTION_FTP_HOST",
|
|
"PRODUCTION_FTP_USER",
|
|
"PRODUCTION_FTP_PASSWORD",
|
|
"PRODUCTION_FTP_PATH",
|
|
"PRODUCTION_ACTIVATION_KEY",
|
|
];
|
|
|
|
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 cpanelNames = [
|
|
"PRODUCTION_CPANEL_USER",
|
|
"PRODUCTION_CPANEL_API_TOKEN",
|
|
"PRODUCTION_CPANEL_API_URL",
|
|
"PRODUCTION_CPANEL_PATH",
|
|
];
|
|
const configuredCpanelNames = cpanelNames.filter((name) => env[name] !== undefined && env[name] !== "");
|
|
let cpanel = null;
|
|
if (configuredCpanelNames.length > 0) {
|
|
if (configuredCpanelNames.length !== cpanelNames.length) {
|
|
throw new DeploymentError("Set all PRODUCTION_CPANEL_* values together or omit them from the FTPS release path.");
|
|
}
|
|
const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER");
|
|
if (!SAFE_COMPONENT.test(cpanelUser)) {
|
|
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
|
|
}
|
|
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),
|
|
};
|
|
}
|
|
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 ftp = {
|
|
host,
|
|
user: requireString(env, "PRODUCTION_FTP_USER"),
|
|
password: requireString(env, "PRODUCTION_FTP_PASSWORD"),
|
|
root: normalizeFtpRoot(requireString(env, "PRODUCTION_FTP_PATH")),
|
|
};
|
|
const activationKey = requireString(env, "PRODUCTION_ACTIVATION_KEY").toLowerCase();
|
|
if (!SHA256.test(activationKey)) {
|
|
throw new DeploymentError("PRODUCTION_ACTIVATION_KEY must be a 64-character hexadecimal key.");
|
|
}
|
|
if (rollbackOnly) {
|
|
return { ftp, activationKey, ...(cpanel ? { 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 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 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,
|
|
...(cpanel ? { cpanel } : {}),
|
|
activationKey,
|
|
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;
|
|
}
|
|
|
|
function releaseIdentityFromTarget(target) {
|
|
const safeTarget = validateReleaseTarget(target);
|
|
const releaseId = safeTarget.split("/")[1];
|
|
const match = releaseId.match(/^([a-f0-9]{40})-(.+)$/i);
|
|
if (!match) {
|
|
throw new DeploymentError("Release target does not contain a full commit and build identity.");
|
|
}
|
|
validateReleaseId(match[2], "release target build ID");
|
|
return { target: safeTarget, releaseId, commit: match[1].toLowerCase(), buildId: match[2] };
|
|
}
|
|
|
|
function parseActivationResult(content, requestId, expectedTarget) {
|
|
const values = new Map();
|
|
for (const line of String(content).trim().split("\n")) {
|
|
const separator = line.indexOf("=");
|
|
if (separator <= 0) throw new DeploymentError("Server-side activation returned an invalid result.");
|
|
const key = line.slice(0, separator);
|
|
const value = line.slice(separator + 1);
|
|
if (values.has(key) || !new Set(["schema_version", "request_id", "status", "target", "message"]).has(key)) {
|
|
throw new DeploymentError("Server-side activation returned an invalid result.");
|
|
}
|
|
values.set(key, value);
|
|
}
|
|
if (
|
|
values.size !== 5 ||
|
|
values.get("schema_version") !== "1" ||
|
|
values.get("request_id") !== requestId ||
|
|
values.get("target") !== expectedTarget ||
|
|
!SAFE_COMPONENT.test(values.get("message") || "")
|
|
) {
|
|
throw new DeploymentError("Server-side activation result did not match the requested release.");
|
|
}
|
|
if (values.get("status") !== "success") {
|
|
throw new DeploymentError(`Server-side release activation failed during ${values.get("message")}.`);
|
|
}
|
|
}
|
|
|
|
export function createLftpTransport(config, dependencies = {}) {
|
|
const runner = dependencies.runner || defaultProcessRunner;
|
|
const fsApi = dependencies.fs || fsPromises;
|
|
const streamFs = dependencies.streamFs || fs;
|
|
const activationTimeoutMs = dependencies.activationTimeoutMs || 180_000;
|
|
const activationPollIntervalMs = dependencies.activationPollIntervalMs || 5_000;
|
|
const activationRequestTtlSeconds = dependencies.activationRequestTtlSeconds || 150;
|
|
const now = dependencies.now || Date.now;
|
|
const sleep =
|
|
dependencies.sleep || (async (milliseconds) => await new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
async function requestActivation(identity, action, archive = {}) {
|
|
const requestId = validateReleaseId(
|
|
dependencies.activationRequestId || `gha-${String(process.env.GITHUB_RUN_ID || now())}-${crypto.randomUUID()}`,
|
|
"activation request ID"
|
|
);
|
|
const archiveName =
|
|
action === "stage" ? validateReleaseId(archive.archiveName, "activation archive name") : "unused";
|
|
const archiveSha256 = action === "stage" ? String(archive.sha256 || "").toLowerCase() : "unused";
|
|
if (action === "stage" && (!archiveName.endsWith(".zip") || !SHA256.test(archiveSha256))) {
|
|
throw new DeploymentError("Activation archive metadata is invalid.");
|
|
}
|
|
if (!Number.isSafeInteger(activationRequestTtlSeconds) || activationRequestTtlSeconds < 60) {
|
|
throw new DeploymentError("Activation request TTL must be at least 60 seconds.");
|
|
}
|
|
const expiresAt = Math.floor(now() / 1000) + activationRequestTtlSeconds;
|
|
const temporaryDirectory = await fsApi.mkdtemp(path.join(os.tmpdir(), "pleno-activation-"));
|
|
const requestPath = path.join(temporaryDirectory, `${requestId}.request`);
|
|
const resultPath = path.join(temporaryDirectory, `${requestId}.result`);
|
|
const requestBody = [
|
|
"schema_version=1",
|
|
`request_id=${requestId}`,
|
|
`action=${action}`,
|
|
`release_id=${identity.releaseId}`,
|
|
`commit_sha=${identity.commit}`,
|
|
`build_id=${identity.buildId}`,
|
|
`archive_name=${archiveName}`,
|
|
`archive_sha256=${archiveSha256}`,
|
|
`expires_at=${expiresAt}`,
|
|
"",
|
|
].join("\n");
|
|
const requestHmac = crypto
|
|
.createHmac("sha256", Buffer.from(config.activationKey, "hex"))
|
|
.update(requestBody)
|
|
.digest("hex");
|
|
const request = `${requestBody}request_hmac=${requestHmac}\n`;
|
|
try {
|
|
await fsApi.writeFile(requestPath, request, { mode: 0o600 });
|
|
const remoteRequest = `activation-requests/${requestId}.request`;
|
|
const remoteResult = `activation-results/${requestId}.result`;
|
|
let queueError;
|
|
try {
|
|
await run([
|
|
`mkdir -p ${lftpQuote("activation-requests")}`,
|
|
`mkdir -p ${lftpQuote("activation-results")}`,
|
|
`rm -f ${lftpQuote(`${remoteRequest}.part`)}`,
|
|
`put ${lftpQuote(requestPath)} -o ${lftpQuote(`${remoteRequest}.part`)}`,
|
|
`mv ${lftpQuote(`${remoteRequest}.part`)} ${lftpQuote(remoteRequest)}`,
|
|
]);
|
|
} catch (error) {
|
|
// The server may have committed the final rename before the FTPS
|
|
// connection failed. Poll through the request lifetime so an
|
|
// ambiguous upload cannot activate after this workflow exits.
|
|
queueError = error;
|
|
}
|
|
|
|
const deadline = now() + activationTimeoutMs;
|
|
let resultDownloaded = false;
|
|
while (now() < deadline) {
|
|
try {
|
|
await run([`get ${lftpQuote(remoteResult)} -o ${lftpQuote(resultPath)}`]);
|
|
resultDownloaded = true;
|
|
break;
|
|
} catch {
|
|
await sleep(activationPollIntervalMs);
|
|
}
|
|
}
|
|
if (!resultDownloaded) {
|
|
throw new DeploymentError(
|
|
queueError
|
|
? "Could not confirm whether the account-scoped activation request was queued before it expired."
|
|
: "Timed out waiting for the account-scoped release activator.",
|
|
queueError ? { cause: queueError } : undefined
|
|
);
|
|
}
|
|
parseActivationResult(await fsApi.readFile(resultPath, "utf8"), requestId, identity.target);
|
|
return identity.target;
|
|
} finally {
|
|
await fsApi.rm(temporaryDirectory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
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 stageAndActivate(uploaded) {
|
|
const identity = {
|
|
target: `releases/${config.releaseId}/dist`,
|
|
releaseId: config.releaseId,
|
|
commit: config.expectedCommit,
|
|
buildId: config.expectedBuildId,
|
|
};
|
|
return await requestActivation(identity, "stage", uploaded);
|
|
},
|
|
|
|
async activateExisting(target) {
|
|
return await requestActivation(releaseIdentityFromTarget(target), "switch");
|
|
},
|
|
|
|
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) ||
|
|
payload?.result?.messages?.find?.(Boolean) ||
|
|
payload?.errors?.find?.(Boolean) ||
|
|
payload?.messages?.find?.(Boolean) ||
|
|
payload?.error ||
|
|
payload?.message ||
|
|
"unknown server error"
|
|
);
|
|
}
|
|
|
|
function uapiResult(payload) {
|
|
return payload?.result && typeof payload.result === "object" ? payload.result : payload;
|
|
}
|
|
|
|
function responseShape(payload) {
|
|
const result = uapiResult(payload);
|
|
const rootKeys = payload && typeof payload === "object" ? Object.keys(payload).sort().join(",") : typeof payload;
|
|
const resultKeys = result && typeof result === "object" ? Object.keys(result).sort().join(",") : typeof result;
|
|
const dataType = Array.isArray(result?.data) ? "array" : typeof result?.data;
|
|
return `response shape root=[${rootKeys}] result=[${resultKeys}] statusType=${typeof result?.status} dataType=${dataType}`;
|
|
}
|
|
|
|
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 (!succeeded(result?.event?.result) || 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");
|
|
const result = uapiResult(payload);
|
|
if (!succeeded(result?.status)) {
|
|
const error = responseMessage(payload);
|
|
const detail = error === "unknown server error" ? `${error}; ${responseShape(payload)}` : error;
|
|
throw new DeploymentError(`cPanel Fileman list_files failed: ${this.redact(detail)}`);
|
|
}
|
|
if (!Array.isArray(result.data)) {
|
|
throw new DeploymentError("cPanel Fileman list_files returned an unexpected data shape.");
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
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.");
|
|
}
|
|
}
|
|
|
|
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 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 transport = dependencies.transport || createLftpTransport(config, dependencies);
|
|
const verify = dependencies.verify || runPublicVerification;
|
|
const publish = dependencies.publish || emitDeploymentOutputs;
|
|
const capturePrevious = dependencies.capturePrevious || capturePublishedReleaseTarget;
|
|
const captureActive = dependencies.captureActive || capturePublishedReleaseTarget;
|
|
const checkCurrent = dependencies.checkCurrent || assertExpectedCommitCurrent;
|
|
|
|
await checkCurrent(config, dependencies);
|
|
const previousTarget = await capturePrevious(config, dependencies);
|
|
|
|
const uploaded = await transport.uploadArchive();
|
|
await checkCurrent(config, dependencies);
|
|
const expectedNewTarget = `releases/${config.releaseId}/dist`;
|
|
let newTarget;
|
|
try {
|
|
newTarget = await transport.stageAndActivate(uploaded);
|
|
} catch (error) {
|
|
let observedTarget;
|
|
try {
|
|
observedTarget = await captureActive(config, dependencies);
|
|
} catch {
|
|
throw error;
|
|
}
|
|
if (observedTarget !== expectedNewTarget) {
|
|
throw error;
|
|
}
|
|
newTarget = observedTarget;
|
|
}
|
|
publish({
|
|
RELEASE_ROLLBACK_TARGET: previousTarget,
|
|
RELEASE_ACTIVE_TARGET: newTarget,
|
|
RELEASE_DEPLOYED_RELEASE_ID: config.releaseId,
|
|
});
|
|
|
|
try {
|
|
await transport.verifyRelease(newTarget);
|
|
await verify(config, dependencies);
|
|
} catch (error) {
|
|
try {
|
|
await transport.activateExisting(previousTarget);
|
|
} catch (rollbackError) {
|
|
throw new DeploymentError(
|
|
"Release verification failed and automatic rollback also failed; production requires immediate attention.",
|
|
{ cause: new AggregateError([error, rollbackError]) }
|
|
);
|
|
}
|
|
throw new DeploymentError("Release verification failed; the previous release was restored.", {
|
|
cause: error,
|
|
});
|
|
}
|
|
|
|
return {
|
|
previousTarget,
|
|
activeTarget: newTarget,
|
|
removed: [],
|
|
retentionWarning:
|
|
"Verified deployment succeeded; inactive release cleanup is deferred because hosted runners cannot use the cPanel metadata API.",
|
|
};
|
|
}
|
|
|
|
export async function rollbackRelease(config, target, dependencies = {}) {
|
|
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
|
const publish = dependencies.publish || emitDeploymentOutputs;
|
|
const safeTarget = validateReleaseTarget(target);
|
|
await transport.activateExisting(safeTarget);
|
|
publish({
|
|
RELEASE_ACTIVE_TARGET: safeTarget,
|
|
RELEASE_DEPLOYED_RELEASE_ID: safeTarget.split("/")[1],
|
|
});
|
|
return { activeTarget: safeTarget };
|
|
}
|