diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b8362bf..e83665d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -272,6 +272,7 @@ jobs: PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }} PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }} PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }} + PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }} PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }} PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }} PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }} @@ -312,6 +313,7 @@ jobs: PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }} PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }} PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }} + PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }} PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }} PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }} PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }} diff --git a/docs/cpanel-frontend-deployment.md b/docs/cpanel-frontend-deployment.md index 5e7c9643..82f6b71a 100644 --- a/docs/cpanel-frontend-deployment.md +++ b/docs/cpanel-frontend-deployment.md @@ -20,13 +20,15 @@ workflow succeeds for a push to `master` in this repository. It then: `master` immediately before deployment. 8. Uploads the ZIP and checksum over certificate-verified explicit FTPS. The uploaded `.part` files are downloaded and hashed before they are renamed. -9. Uses the cPanel Fileman API to extract into a new inactive release. The - extracted tree is downloaded and compared byte-for-byte with the validated - inventory, then `master` is checked again through the read-only workflow - token. -10. Replaces the `current` symlink with a single server-side rename. Public - manifest, asset-integrity, cache-header, API-ping, and role gates run after - activation. A failed public or role gate restores the previous symlink. +9. Uploads an authenticated, bounded-lifetime request into the jailed + deployment directory. A root-owned account-scoped activator validates the + request and archive, extracts an inactive release, verifies its manifest + identity and required files, and replaces `current` with a local + single-filesystem rename. +10. Downloads the extracted tree and compares it byte-for-byte with the + validated inventory. Public manifest, asset-integrity, cache-header, + API-ping, and role gates then run against the active release. A failed gate + asks the same activator to restore the previous immutable target. The fixed `frontend-production` concurrency group is not cancellable. A newer push therefore cannot interrupt an in-progress switch or rollback. @@ -43,6 +45,7 @@ Add these environment **secrets**: - `PRODUCTION_FTP_USER` - `PRODUCTION_FTP_PASSWORD` - `PRODUCTION_FTP_PATH` +- `PRODUCTION_ACTIVATION_KEY` - `PRODUCTION_CPANEL_USER` - `PRODUCTION_CPANEL_API_TOKEN` @@ -53,9 +56,10 @@ deployment unchanged. The cPanel token is separate from the FTP password. Create it in cPanel under **Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment -uses cPanel API2 `Fileman::fileop` because cPanel does not provide a UAPI -replacement for the required extract, symlink, and rename operations. Revoke -and rotate the token if it is ever exposed. +uses the token for fail-closed directory and release-state inspection. It does +not use legacy Fileman mutation calls to replace symlinks: on this server those +calls can follow the target instead of renaming the link itself. Revoke and +rotate the token if it is ever exposed. Add these environment **variables**: @@ -103,11 +107,10 @@ separately; do not reuse the FTP password as an API token. again after leaving the page. 6. Confirm **Yes, I Saved My Token**, then close any local plaintext copy after the GitHub secret has been saved. -7. Before merging, run the deployment preflight against the configured API - origin. It must be able to call cPanel API2 `Fileman::fileop` for extract, - symlink, and rename operations inside `PRODUCTION_CPANEL_PATH`. If the provider - restricts those operations, request the required account feature access; - do not broaden the token or deployment root beyond this cPanel account. +7. Before merging, run the deployment audit against the configured API origin. + It must be able to list `PRODUCTION_CPANEL_PATH`, `current`, and immutable + releases. Do not broaden the token or deployment root beyond this cPanel + account. The current production token is named `github-pleno-vue-production` and expires on 20 July 2027 at 23:59:59 server time. Rotate the GitHub environment @@ -121,10 +124,11 @@ environment, and configured protection rules are evaluated before its secrets are released. The existing live-test, Release Manager, and server-version secrets used by -`release.yml` must remain configured. GitHub-hosted deploy runners install -`lftp` and Playwright Chromium during the job; the existing self-hosted build -runner still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and -`sha256sum`. +`release.yml` must remain configured. The self-hosted deployment job installs +`lftp` job-locally when needed and installs Playwright Chromium. Its runner +still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and `sha256sum`. +The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and +`sha256sum` for the account-scoped activator. ## cPanel layout and one-time bootstrap @@ -169,8 +173,31 @@ Before merging the workflow change, perform a one-time bootstrap in cPanel: 7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route, and the API health request work at `PRODUCTION_FRONTEND_URL`. 8. Test the cPanel token against the exact host and port. The workflow performs - a disposable symlink-replacement preflight and refuses deployment if the - filesystem or hosting policy cannot replace a symlink atomically. + read-only state checks and refuses deployment if `current` or the captured + rollback release is missing. +9. Generate a dedicated 32-byte random activation key. Store its 64-character + hexadecimal form in the protected `frontend-production` environment as + `PRODUCTION_ACTIVATION_KEY`. On the server, install the same value at + `/etc/pleno-release-activator/truckwash.key`, owned by `root:truckwash` and + mode `0440`. The FTPS jail must not expose this key. +10. As `root`, install `scripts/release/cpanel-activate.sh` out of band at + `/usr/local/sbin/truckwash-release-activate.sh`, owned by `root:root` and + mode `0755`. The FTPS principal must not be able to replace or modify this + executable. Then install this one `truckwash` account cron entry without + replacing any other account cron lines: + +```cron +* * * * * /bin/flock -n /home/truckwash/frontend-deployments/.activation.lock /usr/bin/env CPANEL_ACTIVATION_ROOT=/home/truckwash/frontend-deployments CPANEL_ACTIVATION_KEY_FILE=/etc/pleno-release-activator/truckwash.key /bin/sh /usr/local/sbin/truckwash-release-activate.sh >/dev/null 2>&1 +``` + +The workflow can upload release data and bounded-lifetime request files, but +it cannot replace the root-owned executable or read the activation key. The +script authenticates each bounded-lifetime request with HMAC-SHA-256, +accepts only strict filename components and hashes, validates the archive +and manifest, runs a disposable local symlink preflight, journals the prior +pointer for crash recovery, and writes a request-specific result. It runs as +`truckwash`; it does not need root or a shell credential in GitHub. The host +must provide `/bin/sh`, `flock`, `openssl`, `unzip`, `jq`, and `sha256sum`. The automatic deployer intentionally refuses to create the first `current` pointer. This prevents a missing or misconfigured bootstrap from turning the @@ -190,7 +217,8 @@ HTTP checks remain the source of truth for service health. The audit fails closed if any domain record lacks an identity or document root, and restore is blocked while an addon or subdomain is rooted below `public_html`. -If the regression followed the one-time webroot exchange, select `restore` +If the regression followed the one-time webroot exchange and both the active +webroot and selected recovery are physical directories, select `restore` and copy one exact recovery entry from the audit, including the retained `public_html.before-atomic-*` entry created by the bootstrap when applicable. The workflow requires the @@ -198,9 +226,10 @@ typed phrase `RESTORE TO public_html STATE `, using the exact token string from that audit. The token is an optimistic-concurrency guard over the cPanel metadata visible to the audit; it is not a content hash or a substitute for validating the selected recovery. Restore also rejects an -unreadable physical directory. An unreadable root is eligible only when the -independent account-home listing identifies it as a symbolic link. It renames -the current entry to a run-specific `public_html.failed-*` path, restores the retained entry, and +unreadable physical directory. Restore also rejects symbolic-link roots and +recoveries because legacy cPanel Fileman may follow their targets rather than +rename the links. It renames the current physical entry to a run-specific +`public_html.failed-*` path, restores the retained entry, and checks `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. If any mutation response is lost or any check fails, it reconciles the observed account-home entries and reinstates the pre-restore cPanel state. It never diff --git a/scripts/release/cpanel-activate.sh b/scripts/release/cpanel-activate.sh new file mode 100755 index 00000000..c0f8d249 --- /dev/null +++ b/scripts/release/cpanel-activate.sh @@ -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 diff --git a/scripts/release/cpanel-deploy-lib.mjs b/scripts/release/cpanel-deploy-lib.mjs index c85c9145..984b5c1c 100644 --- a/scripts/release/cpanel-deploy-lib.mjs +++ b/scripts/release/cpanel-deploy-lib.mjs @@ -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], diff --git a/scripts/release/cpanel-root-lib.mjs b/scripts/release/cpanel-root-lib.mjs index 859baa0e..845fac2a 100644 --- a/scripts/release/cpanel-root-lib.mjs +++ b/scripts/release/cpanel-root-lib.mjs @@ -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 diff --git a/tests/unit/cpanel-deploy.spec.js b/tests/unit/cpanel-deploy.spec.js index c84b399c..4ca21be8 100644 --- a/tests/unit/cpanel-deploy.spec.js +++ b/tests/unit/cpanel-deploy.spec.js @@ -2,6 +2,8 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -24,6 +26,7 @@ import { const temporaryDirectories = []; const COMMIT_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; +const execFileAsync = promisify(execFile); function validEnv(overrides = {}) { return { @@ -31,6 +34,7 @@ function validEnv(overrides = {}) { PRODUCTION_FTP_USER: "deploy-user", PRODUCTION_FTP_PASSWORD: "password with ' quote", PRODUCTION_FTP_PATH: "/", + PRODUCTION_ACTIVATION_KEY: "c".repeat(64), PRODUCTION_CPANEL_USER: "cpanel-user", PRODUCTION_CPANEL_API_TOKEN: "cpanel-token", PRODUCTION_CPANEL_API_URL: "https://cpanel.example.test:2083/", @@ -101,13 +105,9 @@ describe("cPanel deployment configuration", () => { ); }); - it("does not require deploy artifacts or FTP credentials for rollback-only configuration", () => { + it("does not require deploy artifacts for rollback-only configuration", () => { const env = validEnv(); for (const name of [ - "PRODUCTION_FTP_HOST", - "PRODUCTION_FTP_USER", - "PRODUCTION_FTP_PASSWORD", - "PRODUCTION_FTP_PATH", "PRODUCTION_FRONTEND_URL", "RELEASE_ARCHIVE_PATH", "RELEASE_ARCHIVE_SHA256_PATH", @@ -122,7 +122,12 @@ describe("cPanel deployment configuration", () => { const result = readDeploymentConfig(env, { rollbackOnly: true }); - expect(result.ftp).toEqual({ host: "", user: "", password: "", root: "" }); + expect(result.ftp).toEqual({ + host: "ftp.example.test:21", + user: "deploy-user", + password: "password with ' quote", + root: "/", + }); expect(result.cpanel.root).toBe("public_html/frontend"); }); }); @@ -191,6 +196,228 @@ describe("secure FTPS archive upload", () => { expect(scripts.join("\n")).toContain("rm -f 'archives/pleno-vue-old-release.zip'"); await expect(transport.removeRelease("../outside")).rejects.toThrow("safe filename component"); }); + + it("queues a bounded activation request without replacing the account-scoped executable", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cpanel-activation-request-test-")); + temporaryDirectories.push(directory); + const deploymentConfig = config(); + const target = `releases/${deploymentConfig.releaseId}/dist`; + let uploadedRequest = ""; + const scripts = []; + const runner = vi.fn(async (_command, _args, options) => { + scripts.push(options.input); + const requestMatch = options.input.match( + /put '([^']+\.request)' -o 'activation-requests\/gha-test\.request\.part'/ + ); + if (requestMatch) uploadedRequest = await fs.readFile(requestMatch[1], "utf8"); + const resultMatch = options.input.match(/get 'activation-results\/gha-test\.result' -o '([^']+)'/); + if (resultMatch) { + await fs.writeFile( + resultMatch[1], + `schema_version=1\nrequest_id=gha-test\nstatus=success\ntarget=${target}\nmessage=activated\n` + ); + } + }); + const transport = createLftpTransport(deploymentConfig, { + runner, + activationRequestId: "gha-test", + now: () => 2_000_000_000_000, + }); + + await expect(transport.stageAndActivate({ archiveName: "release.zip", sha256: "a".repeat(64) })).resolves.toBe( + target + ); + expect(scripts[0]).not.toContain("release-activate.sh"); + expect(uploadedRequest).toContain(`release_id=${deploymentConfig.releaseId}`); + expect(uploadedRequest).toContain(`commit_sha=${deploymentConfig.expectedCommit}`); + expect(uploadedRequest).toContain("archive_sha256=" + "a".repeat(64)); + expect(uploadedRequest).toContain("expires_at=2000000150"); + const [authenticatedBody, requestHmac] = uploadedRequest.split("request_hmac="); + expect(requestHmac.trim()).toBe( + crypto + .createHmac("sha256", Buffer.from(deploymentConfig.activationKey, "hex")) + .update(authenticatedBody) + .digest("hex") + ); + }); + + it("polls for a committed result when the request upload outcome is ambiguous", async () => { + const deploymentConfig = config(); + const target = `releases/${deploymentConfig.releaseId}/dist`; + let calls = 0; + const runner = vi.fn(async (_command, _args, options) => { + calls += 1; + if (calls === 1) throw new Error("FTPS disconnected after the final rename"); + const resultMatch = options.input.match(/get 'activation-results\/gha-ambiguous\.result' -o '([^']+)'/); + if (resultMatch) { + await fs.writeFile( + resultMatch[1], + `schema_version=1\nrequest_id=gha-ambiguous\nstatus=success\ntarget=${target}\nmessage=activated\n` + ); + } + }); + const transport = createLftpTransport(deploymentConfig, { + runner, + activationRequestId: "gha-ambiguous", + }); + + await expect(transport.activateExisting(target)).resolves.toBe(target); + expect(runner).toHaveBeenCalledTimes(2); + }); +}); + +describe("account-scoped release activator", () => { + it("stages a validated archive, switches current atomically, and leaves current untouched on failure", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cpanel-activator-test-")); + temporaryDirectories.push(directory); + const activationRoot = path.join(directory, "frontend-deployments"); + const activationKey = "c".repeat(64); + const activationKeyPath = path.join(directory, "activation.key"); + const packageRoot = path.join(directory, "package"); + const dist = path.join(packageRoot, "dist"); + await fs.mkdir(path.join(activationRoot, "archives"), { recursive: true }); + await fs.mkdir(path.join(activationRoot, "activation-requests"), { recursive: true }); + await fs.writeFile(activationKeyPath, `${activationKey}\n`); + const writeDist = async (directoryPath, commit, buildId, marker = "app") => { + await fs.mkdir(directoryPath, { recursive: true }); + await fs.writeFile(path.join(directoryPath, "index.html"), `
`); + await fs.writeFile(path.join(directoryPath, ".htaccess"), "DirectoryIndex index.html\n"); + await fs.writeFile(path.join(directoryPath, "release-entry.json"), "{}\n"); + await fs.writeFile( + path.join(directoryPath, "release-manifest.json"), + JSON.stringify({ commit_sha: commit, build_id: buildId }) + ); + }; + const oldCommit = "a".repeat(40); + const oldBuildId = "122-1"; + const oldReleaseId = `${oldCommit}-${oldBuildId}`; + const oldTarget = `releases/${oldReleaseId}/dist`; + await writeDist(path.join(activationRoot, oldTarget), oldCommit, oldBuildId, "old-app"); + await fs.symlink(oldTarget, path.join(activationRoot, "current")); + await writeDist(dist, COMMIT_SHA, "123-1"); + const releaseId = `${COMMIT_SHA}-123-1`; + const target = `releases/${releaseId}/dist`; + const archiveName = `pleno-vue-${releaseId}.zip`; + const archivePath = path.join(activationRoot, "archives", archiveName); + await execFileAsync("zip", ["-qr", archivePath, "dist"], { cwd: packageRoot }); + const archive = await fs.readFile(archivePath); + const archiveSha256 = crypto.createHash("sha256").update(archive).digest("hex"); + const writeRequest = async ( + requestId, + hash, + requestedReleaseId = releaseId, + requestedBuildId = "123-1", + action = "stage", + expiresAt = Math.floor(Date.now() / 1000) + 300 + ) => { + const requestBody = [ + "schema_version=1", + `request_id=${requestId}`, + `action=${action}`, + `release_id=${requestedReleaseId}`, + `commit_sha=${COMMIT_SHA}`, + `build_id=${requestedBuildId}`, + `archive_name=${action === "stage" ? archiveName : "unused"}`, + `archive_sha256=${action === "stage" ? hash : "unused"}`, + `expires_at=${expiresAt}`, + "", + ].join("\n"); + const requestHmac = crypto + .createHmac("sha256", Buffer.from(activationKey, "hex")) + .update(requestBody) + .digest("hex"); + await fs.writeFile( + path.join(activationRoot, "activation-requests", `${requestId}.request`), + `${requestBody}request_hmac=${requestHmac}\n` + ); + }; + const activator = path.resolve("scripts/release/cpanel-activate.sh"); + const activatorEnvironment = { + ...process.env, + CPANEL_ACTIVATION_ROOT: activationRoot, + CPANEL_ACTIVATION_KEY_FILE: activationKeyPath, + }; + + await writeRequest("activation-success", archiveSha256); + await execFileAsync("sh", [activator], { env: activatorEnvironment }); + expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`); + expect(await fs.readFile(path.join(activationRoot, "current", "index.html"), "utf8")).toContain('id="app"'); + expect( + await fs.readFile(path.join(activationRoot, "activation-results", "activation-success.result"), "utf8") + ).toContain("status=success"); + + const processedSuccess = path.join( + activationRoot, + "activation-requests", + "processed", + "activation-success.processed" + ); + const processingSuccess = path.join(activationRoot, "activation-requests", "activation-success.processing"); + await fs.rename(processedSuccess, processingSuccess); + await fs.rm(path.join(activationRoot, "activation-results", "activation-success.result")); + await execFileAsync("sh", [activator], { env: activatorEnvironment }); + expect( + await fs.readFile(path.join(activationRoot, "activation-results", "activation-success.result"), "utf8") + ).toContain("status=success"); + + const failedReleaseId = `${COMMIT_SHA}-123-2`; + await writeRequest("activation-failure", "0".repeat(64), failedReleaseId, "123-2"); + await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow(); + expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`); + expect( + await fs.readFile(path.join(activationRoot, "activation-results", "activation-failure.result"), "utf8") + ).toContain("status=failure"); + + const existingReleaseId = `${COMMIT_SHA}-123-3`; + await writeDist(path.join(activationRoot, "releases", existingReleaseId, "dist"), COMMIT_SHA, "123-3"); + await writeRequest("activation-existing", archiveSha256, existingReleaseId, "123-3"); + await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow(); + expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`); + expect( + await fs.readFile(path.join(activationRoot, "activation-results", "activation-existing.result"), "utf8") + ).toContain("message=release_already_exists"); + + await writeRequest( + "activation-expired", + archiveSha256, + `${COMMIT_SHA}-123-4`, + "123-4", + "stage", + Math.floor(Date.now() / 1000) - 1 + ); + await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow(); + expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`); + + await writeRequest("activation-unauthenticated", archiveSha256, `${COMMIT_SHA}-123-5`, "123-5"); + const unauthenticatedRequest = path.join( + activationRoot, + "activation-requests", + "activation-unauthenticated.request" + ); + await fs.writeFile( + unauthenticatedRequest, + ( + await fs.readFile(unauthenticatedRequest, "utf8") + ).replace(/request_hmac=[a-f0-9]{64}/, `request_hmac=${"0".repeat(64)}`) + ); + await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow(); + expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(target); + + await fs.rm(path.join(activationRoot, target, "index.html")); + await fs.rename(processedSuccess, processingSuccess); + await fs.rm(path.join(activationRoot, "activation-results", "activation-success.result")); + await fs.symlink(oldTarget, path.join(activationRoot, "current.activation-success.rollback")); + await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow(); + expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(oldTarget); + expect( + await fs.readFile(path.join(activationRoot, "activation-results", "activation-success.result"), "utf8") + ).toContain("message=post_activation_validation"); + + await fs.writeFile(path.join(activationRoot, target, "index.html"), '
'); + await writeRequest("activation-switch", "unused", releaseId, "123-1", "switch"); + await execFileAsync("sh", [activator], { env: activatorEnvironment }); + expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(target); + }); }); describe("cPanel Fileman adapter", () => { @@ -214,15 +441,16 @@ describe("cPanel Fileman adapter", () => { const deploymentConfig = config(); const client = new CpanelFilemanClient(deploymentConfig, { fetchImpl }); - await client.extract("public_html/frontend/staging/release/archive.zip", "public_html/frontend/staging/release"); + await client.remove("public_html/frontend/staging/obsolete-release"); expect(requests).toHaveLength(1); expect(requests[0].url.protocol).toBe("https:"); expect(requests[0].url.searchParams.get("cpanel_jsonapi_apiversion")).toBe("2"); expect(requests[0].url.searchParams.get("cpanel_jsonapi_module")).toBe("Fileman"); expect(requests[0].url.searchParams.get("cpanel_jsonapi_func")).toBe("fileop"); - expect(requests[0].url.searchParams.get("op")).toBe("extract"); - expect(requests[0].url.searchParams.get("destfiles")).toBe("public_html/frontend/staging/release"); + expect(requests[0].url.searchParams.get("op")).toBe("trash"); + expect(requests[0].url.searchParams.get("sourcefiles")).toBe("public_html/frontend/staging/obsolete-release"); + expect(requests[0].url.searchParams.has("destfiles")).toBe(false); expect(requests[0].options.headers.Authorization).toBe("cpanel cpanel-user:cpanel-token"); }); @@ -315,6 +543,9 @@ function inMemorySwitchClient(deploymentConfig, initialTarget) { get current() { return current; }, + setCurrent(target) { + current = target; + }, async list(directory) { if (directory === root) return entriesForRoot(); if (directory === `${root}/archives`) return []; @@ -359,18 +590,22 @@ describe("activation, rollback, and retention", () => { const client = inMemorySwitchClient(deploymentConfig, previousTarget); const outputs = []; const verifyRelease = vi.fn(); + const transport = { + uploadArchive: async () => ({ archiveName: "release.zip", sha256: "a".repeat(64) }), + stageAndActivate: async () => { + client.setCurrent(newTarget); + return newTarget; + }, + activateExisting: async (target) => client.setCurrent(target), + verifyRelease, + }; await expect( deployRelease(deploymentConfig, { client, - transport: { - uploadArchive: async () => ({ archiveName: "release.zip" }), - verifyRelease, - }, - preflight: async () => {}, + transport, capturePrevious: async () => previousTarget, checkCurrent: async () => {}, - stage: async () => newTarget, verify: async () => { throw new Error("application gate failed"); }, @@ -387,34 +622,71 @@ describe("activation, rollback, and retention", () => { }); }); + it("reconciles a lost activation result before verifying the committed release", async () => { + const deploymentConfig = config(); + const previousTarget = `releases/${"a".repeat(40)}-122-1/dist`; + const newTarget = `releases/${deploymentConfig.releaseId}/dist`; + const client = inMemorySwitchClient(deploymentConfig, previousTarget); + const verifyRelease = vi.fn(); + const verify = vi.fn(); + const transport = { + uploadArchive: async () => ({ archiveName: "release.zip", sha256: "a".repeat(64) }), + stageAndActivate: async () => { + client.setCurrent(newTarget); + throw new DeploymentError("Timed out waiting for the account-scoped release activator."); + }, + activateExisting: vi.fn(), + verifyRelease, + }; + + await expect( + deployRelease(deploymentConfig, { + client, + transport, + capturePrevious: async () => previousTarget, + captureActive: async () => newTarget, + checkCurrent: async () => {}, + verify, + prune: async () => [], + publish: () => {}, + }) + ).resolves.toMatchObject({ activeTarget: newTarget }); + + expect(verifyRelease).toHaveBeenCalledWith(newTarget); + expect(verify).toHaveBeenCalled(); + expect(transport.activateExisting).not.toHaveBeenCalled(); + }); + it("supports an idempotent rollback-only workflow step", async () => { const deploymentConfig = config(); const target = "releases/previous-release/dist"; const client = inMemorySwitchClient(deploymentConfig, target); const publish = vi.fn(); + const activateExisting = vi.fn(async () => target); await expect( rollbackRelease(deploymentConfig, target, { client, - preflight: async () => {}, + transport: { activateExisting }, publish, }) ).resolves.toMatchObject({ activeTarget: target }); + expect(activateExisting).toHaveBeenCalledWith(target); expect(publish).toHaveBeenCalledWith(expect.objectContaining({ RELEASE_ACTIVE_TARGET: target })); }); it("rejects a rollback-only target that no longer exists before running the preflight", async () => { const deploymentConfig = config(); - const preflight = vi.fn(); + const activateExisting = vi.fn(); const client = { list: vi.fn(async () => []) }; await expect( rollbackRelease(deploymentConfig, "releases/missing-release/dist", { client, - preflight, + transport: { activateExisting }, }) ).rejects.toThrow("rollback release directory does not exist"); - expect(preflight).not.toHaveBeenCalled(); + expect(activateExisting).not.toHaveBeenCalled(); }); it("prunes only inactive releases and preserves active and rollback targets", async () => { diff --git a/tests/unit/cpanel-root.spec.js b/tests/unit/cpanel-root.spec.js index 2fde0d4a..fcc90af1 100644 --- a/tests/unit/cpanel-root.spec.js +++ b/tests/unit/cpanel-root.spec.js @@ -273,7 +273,41 @@ describe("cPanel retained webroot restore", () => { `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, { client, runId: "unreadable-dir" } ) - ).rejects.toThrow("only a top-level symbolic link"); + ).rejects.toThrow("could not be inspected"); + expect(client.rename).not.toHaveBeenCalled(); + }); + + it("refuses Fileman restore when the active webroot is a symbolic link", async () => { + const client = auditClient(); + client.home[0].type = "link"; + const state = await auditRoot(config(), { client }); + + await expect( + restoreRoot( + config(), + "public_html.recovery-before-bootstrap", + state.stateToken, + `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, + { client, runId: "linked-root" } + ) + ).rejects.toThrow("physical current webroot"); + expect(client.rename).not.toHaveBeenCalled(); + }); + + it("refuses Fileman restore from a symbolic-link recovery candidate", async () => { + const client = auditClient(); + client.home[1].type = "link"; + const state = await auditRoot(config(), { client }); + + await expect( + restoreRoot( + config(), + "public_html.recovery-before-bootstrap", + state.stateToken, + `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, + { client, runId: "linked-recovery" } + ) + ).rejects.toThrow("physical retained directory"); expect(client.rename).not.toHaveBeenCalled(); });