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:
Executable
+399
@@ -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
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user