Activate cPanel releases with a pinned account runner (#193)

Replace unsafe legacy Fileman symlink activation with an authenticated, root-owned account runner and crash-safe pointer reconciliation.
This commit is contained in:
Jeppe B
2026-07-20 18:20:03 +02:00
committed by GitHub
parent 88eda43560
commit 1a959c2ce8
7 changed files with 981 additions and 242 deletions
+399
View File
@@ -0,0 +1,399 @@
#!/bin/sh
set -u
umask 077
script_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
activation_root=${CPANEL_ACTIVATION_ROOT:-$script_directory}
activation_key_file=${CPANEL_ACTIVATION_KEY_FILE:-/etc/pleno-release-activator/truckwash.key}
case "$activation_root" in
""|"/") exit 1 ;;
esac
if [ ! -f "$activation_key_file" ]; then
exit 1
fi
IFS= read -r activation_key <"$activation_key_file" || [ -n "$activation_key" ] || exit 1
sha256_value_pending=$activation_key
if ! printf '%s\n' "$sha256_value_pending" | grep -Eq '^[a-f0-9]{64}$'; then
exit 1
fi
requests_directory="$activation_root/activation-requests"
results_directory="$activation_root/activation-results"
processed_directory="$requests_directory/processed"
state_directory="$activation_root/activation-state"
mkdir -p -- \
"$requests_directory" \
"$results_directory" \
"$processed_directory" \
"$state_directory" \
"$activation_root/archives" \
"$activation_root/releases" \
"$activation_root/staging" || exit 1
safe_component() {
printf '%s\n' "$1" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,179}$'
}
full_sha() {
printf '%s\n' "$1" | grep -Eq '^[a-f0-9]{40}$'
}
sha256_value() {
printf '%s\n' "$1" | grep -Eq '^[a-f0-9]{64}$'
}
decimal_timestamp() {
printf '%s\n' "$1" | grep -Eq '^[0-9]{10,12}$'
}
valid_target() {
candidate_target=$1
candidate_release=${candidate_target#releases/}
candidate_release=${candidate_release%/dist}
[ "$candidate_target" = "releases/$candidate_release/dist" ] &&
safe_component "$candidate_release"
}
target_identity() {
identity_target=$1
identity_release=${identity_target#releases/}
identity_release=${identity_release%/dist}
identity_commit=${identity_release%%-*}
identity_build=${identity_release#*-}
full_sha "$identity_commit" &&
[ "$identity_build" != "$identity_release" ] &&
safe_component "$identity_build"
}
validate_release() {
validated_target=$1
valid_target "$validated_target" || return 1
target_identity "$validated_target" || return 1
validated_dist="$activation_root/$validated_target"
[ -f "$validated_dist/index.html" ] &&
[ -f "$validated_dist/.htaccess" ] &&
[ -f "$validated_dist/release-manifest.json" ] &&
[ -f "$validated_dist/release-entry.json" ] &&
grep -Eq '^DirectoryIndex[[:space:]]+index\.html([[:space:]]|$)' "$validated_dist/.htaccess" &&
jq -e --arg commit "$identity_commit" --arg build "$identity_build" \
'.commit_sha == $commit and .build_id == $build' \
"$validated_dist/release-manifest.json" >/dev/null
}
write_result() {
result_status=$1
result_target=$2
result_message=$3
result_part="$results_directory/$request_id.result.part"
result_path="$results_directory/$request_id.result"
{
printf 'schema_version=1\n'
printf 'request_id=%s\n' "$request_id"
printf 'status=%s\n' "$result_status"
printf 'target=%s\n' "$result_target"
printf 'message=%s\n' "$result_message"
} >"$result_part" && mv -Tf -- "$result_part" "$result_path"
}
write_state() {
state_phase=$1
state_part="$state_directory/$request_id.state.part"
{
printf 'schema_version=1\n'
printf 'request_id=%s\n' "$request_id"
printf 'previous_target=%s\n' "$previous_target"
printf 'target=%s\n' "$target"
printf 'phase=%s\n' "$state_phase"
} >"$state_part" && mv -Tf -- "$state_part" "$state_path"
}
finish_request() {
final_status=$1
final_message=$2
write_result "$final_status" "$target" "$final_message" || exit 1
mv -Tf -- "$processing_path" "$processed_directory/$request_id.processed" || exit 1
[ "$final_status" = "success" ]
}
restore_previous() {
rollback_link="$activation_root/current.$request_id.rollback"
[ "$previous_target" != "$target" ] || return 1
validate_release "$previous_target" || return 1
if [ -L "$rollback_link" ]; then
[ "$(readlink "$rollback_link")" = "$previous_target" ] || return 1
elif [ -e "$rollback_link" ]; then
return 1
else
ln -s "$previous_target" "$rollback_link" || return 1
fi
mv -Tf -- "$rollback_link" "$activation_root/current" &&
[ "$(readlink "$activation_root/current")" = "$previous_target" ]
}
set -- "$requests_directory"/*.processing
if [ -e "$1" ]; then
processing_path=$1
request_name=${processing_path##*/}
request_id=${request_name%.processing}
else
set -- "$requests_directory"/*.request
[ -e "$1" ] || exit 0
request_path=$1
request_name=${request_path##*/}
request_id=${request_name%.request}
processing_path="$requests_directory/$request_id.processing"
if ! safe_component "$request_id" || [ -e "$processing_path" ]; then
exit 1
fi
mv -T -- "$request_path" "$processing_path" || exit 1
fi
if ! safe_component "$request_id"; then
exit 1
fi
schema_version=
parsed_request_id=
action=
release_id=
commit_sha=
build_id=
archive_name=
archive_sha256=
expires_at=
request_hmac=
parse_error=0
while IFS='=' read -r key value; do
case "$key" in
schema_version) [ -z "$schema_version" ] && schema_version=$value || parse_error=1 ;;
request_id) [ -z "$parsed_request_id" ] && parsed_request_id=$value || parse_error=1 ;;
action) [ -z "$action" ] && action=$value || parse_error=1 ;;
release_id) [ -z "$release_id" ] && release_id=$value || parse_error=1 ;;
commit_sha) [ -z "$commit_sha" ] && commit_sha=$value || parse_error=1 ;;
build_id) [ -z "$build_id" ] && build_id=$value || parse_error=1 ;;
archive_name) [ -z "$archive_name" ] && archive_name=$value || parse_error=1 ;;
archive_sha256) [ -z "$archive_sha256" ] && archive_sha256=$value || parse_error=1 ;;
expires_at) [ -z "$expires_at" ] && expires_at=$value || parse_error=1 ;;
request_hmac) [ -z "$request_hmac" ] && request_hmac=$value || parse_error=1 ;;
*) parse_error=1 ;;
esac
done <"$processing_path"
target="invalid"
activation_step=request_validation
activation_ok=0
expected_hmac=$(
{
printf 'schema_version=%s\n' "$schema_version"
printf 'request_id=%s\n' "$parsed_request_id"
printf 'action=%s\n' "$action"
printf 'release_id=%s\n' "$release_id"
printf 'commit_sha=%s\n' "$commit_sha"
printf 'build_id=%s\n' "$build_id"
printf 'archive_name=%s\n' "$archive_name"
printf 'archive_sha256=%s\n' "$archive_sha256"
printf 'expires_at=%s\n' "$expires_at"
} | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$activation_key" 2>/dev/null | awk '{print $NF}'
)
if [ "$parse_error" -eq 0 ] &&
[ "$schema_version" = "1" ] &&
[ "$parsed_request_id" = "$request_id" ] &&
safe_component "$release_id" &&
[ "$release_id" = "$commit_sha-$build_id" ] &&
full_sha "$commit_sha" &&
safe_component "$build_id" &&
decimal_timestamp "$expires_at" &&
sha256_value "$request_hmac" &&
[ "$request_hmac" = "$expected_hmac" ] &&
{ [ "$action" = "stage" ] || [ "$action" = "switch" ]; }; then
target="releases/$release_id/dist"
activation_ok=1
fi
state_path="$state_directory/$request_id.state"
previous_target=
state_phase=
if [ "$activation_ok" -eq 1 ] && [ -f "$state_path" ]; then
state_schema=
state_request_id=
state_previous_target=
state_target=
state_parse_error=0
while IFS='=' read -r key value; do
case "$key" in
schema_version) [ -z "$state_schema" ] && state_schema=$value || state_parse_error=1 ;;
request_id) [ -z "$state_request_id" ] && state_request_id=$value || state_parse_error=1 ;;
previous_target) [ -z "$state_previous_target" ] && state_previous_target=$value || state_parse_error=1 ;;
target) [ -z "$state_target" ] && state_target=$value || state_parse_error=1 ;;
phase) [ -z "$state_phase" ] && state_phase=$value || state_parse_error=1 ;;
*) state_parse_error=1 ;;
esac
done <"$state_path"
if [ "$state_parse_error" -ne 0 ] ||
[ "$state_schema" != "1" ] ||
[ "$state_request_id" != "$request_id" ] ||
[ "$state_target" != "$target" ] ||
! valid_target "$state_previous_target" ||
{ [ "$state_phase" != "prepared" ] && [ "$state_phase" != "activated" ]; }; then
activation_step=state_validation
activation_ok=0
else
previous_target=$state_previous_target
fi
fi
if [ "$activation_ok" -eq 1 ] && [ -z "$previous_target" ]; then
activation_step=current_validation
if [ ! -L "$activation_root/current" ]; then
activation_ok=0
else
previous_target=$(readlink "$activation_root/current")
if ! validate_release "$previous_target" || ! write_state prepared; then
activation_ok=0
else
state_phase=prepared
fi
fi
fi
if [ "$activation_ok" -eq 1 ] && [ "$(readlink "$activation_root/current" 2>/dev/null || true)" = "$target" ]; then
if validate_release "$target"; then
write_state activated || exit 1
finish_request success activated
exit $?
fi
if restore_previous; then
finish_request failure post_activation_validation
else
finish_request failure rollback_failed
fi
exit $?
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=request_expired
current_epoch=$(date +%s)
if [ "$expires_at" -lt "$current_epoch" ]; then
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=atomic_preflight
probe="$activation_root/staging/.activation-preflight-$request_id"
if [ -e "$probe" ] || [ -L "$probe" ]; then
activation_ok=0
elif mkdir -p -- "$probe/first" "$probe/second" &&
: >"$probe/first/first-marker" &&
: >"$probe/second/second-marker" &&
ln -s first "$probe/current" &&
ln -s second "$probe/current.next" &&
mv -Tf -- "$probe/current.next" "$probe/current" &&
[ -f "$probe/current/second-marker" ] &&
[ ! -e "$probe/current/first-marker" ]; then
if ! rm -rf -- "$probe"; then
activation_ok=0
fi
else
activation_ok=0
fi
fi
release_root="$activation_root/releases/$release_id"
release_dist="$release_root/dist"
if [ "$activation_ok" -eq 1 ] && [ "$action" = "stage" ]; then
activation_step=archive_validation
if ! safe_component "$archive_name" ||
! sha256_value "$archive_sha256" ||
[ "${archive_name##*.}" != "zip" ]; then
activation_ok=0
fi
archive_path="$activation_root/archives/$archive_name"
staging_root="$activation_root/staging/$release_id.$request_id.pending"
if [ "$activation_ok" -eq 1 ]; then
activation_step=release_already_exists
if [ -e "$release_root" ] || [ -L "$release_root" ]; then
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=archive_extraction
if [ -e "$staging_root" ] || [ -L "$staging_root" ] ||
[ ! -f "$archive_path" ] ||
[ "$(sha256sum "$archive_path" | awk '{print $1}')" != "$archive_sha256" ] ||
! mkdir -p -- "$staging_root" ||
! unzip -q "$archive_path" -d "$staging_root"; then
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=release_validation
if [ -f "$staging_root/dist/index.html" ] &&
[ -f "$staging_root/dist/.htaccess" ] &&
[ -f "$staging_root/dist/release-manifest.json" ] &&
[ -f "$staging_root/dist/release-entry.json" ] &&
grep -Eq '^DirectoryIndex[[:space:]]+index\.html([[:space:]]|$)' "$staging_root/dist/.htaccess" &&
jq -e --arg commit "$commit_sha" --arg build "$build_id" \
'.commit_sha == $commit and .build_id == $build' \
"$staging_root/dist/release-manifest.json" >/dev/null &&
mv -T -- "$staging_root" "$release_root"; then
chmod 0755 "$release_dist"
else
activation_ok=0
fi
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=release_validation
if ! validate_release "$target"; then
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=request_expired
current_epoch=$(date +%s)
if [ "$expires_at" -lt "$current_epoch" ]; then
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=atomic_activation
next_link="$activation_root/current.$request_id.next"
if [ -e "$next_link" ] || [ -L "$next_link" ]; then
activation_ok=0
elif ln -s "$target" "$next_link" &&
mv -Tf -- "$next_link" "$activation_root/current"; then
write_state activated || true
if [ "$(readlink "$activation_root/current")" = "$target" ] &&
validate_release "$target"; then
activation_ok=1
else
activation_ok=0
activation_step=post_activation_validation
if ! restore_previous; then
activation_step=rollback_failed
fi
fi
else
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
finish_request success activated
else
finish_request failure "$activation_step"
fi
+188 -191
View File
@@ -17,6 +17,7 @@ const REQUIRED_ENV = [
"PRODUCTION_FTP_USER",
"PRODUCTION_FTP_PASSWORD",
"PRODUCTION_FTP_PATH",
"PRODUCTION_ACTIVATION_KEY",
"PRODUCTION_CPANEL_USER",
"PRODUCTION_CPANEL_API_TOKEN",
"PRODUCTION_CPANEL_API_URL",
@@ -27,6 +28,11 @@ const REQUIRED_ENV = [
"RELEASE_EXPECTED_BUILD_ID",
];
const ROLLBACK_REQUIRED_ENV = [
"PRODUCTION_FTP_HOST",
"PRODUCTION_FTP_USER",
"PRODUCTION_FTP_PASSWORD",
"PRODUCTION_FTP_PATH",
"PRODUCTION_ACTIVATION_KEY",
"PRODUCTION_CPANEL_USER",
"PRODUCTION_CPANEL_API_TOKEN",
"PRODUCTION_CPANEL_API_URL",
@@ -157,11 +163,22 @@ export function readDeploymentConfig(env = process.env, options = {}) {
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: { host: "", user: "", password: "", root: "" },
cpanel,
};
return { ftp, cpanel, activationKey };
}
const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH;
@@ -172,10 +189,6 @@ export function readDeploymentConfig(env = process.env, options = {}) {
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.");
@@ -192,7 +205,6 @@ export function readDeploymentConfig(env = process.env, options = {}) {
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;
@@ -211,13 +223,9 @@ export function readDeploymentConfig(env = process.env, options = {}) {
}
return {
ftp: {
host,
user: requireString(env, "PRODUCTION_FTP_USER"),
password: requireString(env, "PRODUCTION_FTP_PASSWORD"),
root: normalizeFtpRoot(ftpPath),
},
ftp,
cpanel,
activationKey,
archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")),
checksumPath: path.resolve(checksumPath),
inventoryPath: path.resolve(requireString(env, "RELEASE_INVENTORY_PATH")),
@@ -359,10 +367,53 @@ async function readExpectedInventory(config, fsApi = fsPromises) {
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 {
@@ -375,6 +426,87 @@ export function createLftpTransport(config, dependencies = {}) {
}
}
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);
@@ -458,6 +590,20 @@ export function createLftpTransport(config, dependencies = {}) {
}
},
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}`)}`]);
@@ -629,35 +775,6 @@ export class CpanelFilemanClient {
return 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);
}
@@ -707,24 +824,6 @@ export async function assertReleaseTargetExists(client, config, target) {
}
}
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");
@@ -736,16 +835,6 @@ async function assertCurrentLink(client, config) {
}
}
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);
@@ -820,121 +909,17 @@ export async function assertExpectedCommitCurrent(config, options = {}) {
}
}
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");
if (!findEntry(await client.list(config.cpanel.root), "archives")) {
throw new DeploymentError("cPanel deployment archives directory is missing; bootstrap is incomplete.");
}
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));
@@ -1008,16 +993,14 @@ export function emitDeploymentOutputs(values, env = process.env, fsApi = fs) {
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 captureActive = dependencies.captureActive || 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);
@@ -1026,10 +1009,23 @@ export async function deployRelease(config, dependencies = {}) {
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);
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,
@@ -1037,17 +1033,19 @@ export async function deployRelease(config, dependencies = {}) {
});
try {
await assertReleaseTargetExists(client, config, newTarget);
await transport.verifyRelease(newTarget);
await verify(config, dependencies);
} catch (error) {
try {
await atomicSwitch(client, config, previousTarget);
await transport.activateExisting(previousTarget);
} catch (rollbackError) {
throw new DeploymentError(
"Public verification failed and automatic rollback also failed; production requires immediate attention.",
"Release 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.", {
throw new DeploymentError("Release verification failed; the previous release was restored.", {
cause: error,
});
}
@@ -1070,12 +1068,11 @@ export async function deployRelease(config, dependencies = {}) {
export async function rollbackRelease(config, target, dependencies = {}) {
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
const preflight = dependencies.preflight || preflightAtomicSwitch;
const transport = dependencies.transport || createLftpTransport(config, dependencies);
const publish = dependencies.publish || emitDeploymentOutputs;
const safeTarget = validateReleaseTarget(target);
await assertReleaseTargetExists(client, config, safeTarget);
await preflight(client, config);
await atomicSwitch(client, config, safeTarget);
await transport.activateExisting(safeTarget);
publish({
RELEASE_ACTIVE_TARGET: safeTarget,
RELEASE_DEPLOYED_RELEASE_ID: safeTarget.split("/")[1],
+12 -6
View File
@@ -464,19 +464,25 @@ export async function restoreRoot(config, recovery, expectedStateToken, confirma
if (before.stateToken !== expectedStateToken) {
throw new DeploymentError("The cPanel webroot changed after the audit; run a new audit before restoring.");
}
if (!before.recoveryCandidates.some((entry) => entry.name === safeRecovery)) {
const selectedRecovery = before.recoveryCandidates.find((entry) => entry.name === safeRecovery);
if (!selectedRecovery) {
throw new DeploymentError("The requested recovery entry does not exist in the current cPanel state.");
}
if (selectedRecovery.type !== "dir") {
throw new DeploymentError(
"Automatic restore requires a physical retained directory; cPanel Fileman may follow symbolic links."
);
}
if (!before.webroot)
throw new DeploymentError("The current public_html entry is missing; refusing an ambiguous restore.");
if (!new Set(["dir", "link"]).has(before.webroot.type)) {
throw new DeploymentError("The current public_html entry type is unknown; refusing an ambiguous restore.");
}
if (!before.webrootAccess.accessible && before.webroot.type !== "link") {
if (before.webroot.type !== "dir") {
throw new DeploymentError(
"The current public_html directory could not be inspected; only a top-level symbolic link may use unreadable-root recovery."
"Automatic restore requires a physical current webroot; cPanel Fileman may follow symbolic links."
);
}
if (!before.webrootAccess.accessible) {
throw new DeploymentError("The current public_html directory could not be inspected.");
}
if (before.nestedDomainRoots.length > 0) {
throw new DeploymentError(
`Refusing to replace public_html while nested domain document roots exist: ${before.nestedDomainRoots