| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/client-portal-laravel/current/scripts/deploy/ |
Upload File : |
/**
* Laravel Client Portal - Rollback Script
*
* Instantly rollback to a previous release by switching the symlink.
*
* Usage: node scripts/rollback.js [options]
* --releases=N Number of releases to roll back (default: 1)
* --to=TIMESTAMP Roll back to a specific release by name
* --dry-run Show what would happen without making changes
*
* Examples:
* node scripts/rollback.js # Roll back 1 release
* node scripts/rollback.js --releases=2 # Roll back 2 releases
* node scripts/rollback.js --to=20260115120000 # Roll back to specific release
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const { NodeSSH } = require('node-ssh');
// Load environment: prefer .env.deployment, fallback to .env
const PROJECT_ROOT = path.join(__dirname, '..', '..');
const envDeploymentPath = path.join(PROJECT_ROOT, '.env.deployment');
const envPath = path.join(PROJECT_ROOT, '.env');
const envFileUsed = fs.existsSync(envDeploymentPath) ? envDeploymentPath : envPath;
require('dotenv').config({ path: envFileUsed });
console.log(`[ENV] Loaded: ${path.basename(envFileUsed)}`);
// ==== TARGET ENVIRONMENT (PLAN-281 §4.1) ====
// Mirrors deploy-linux.cjs so a rollback hits the right tree + supervisor
// groups. Default: production.
const CLI_ARGS = process.argv.slice(2);
function parseTargetEnv(args) {
const flag = args.find((a) => a.startsWith('--env='));
if (!flag) return 'production';
const value = flag.split('=')[1];
if (value !== 'production' && value !== 'staging') {
console.error(`❌ Invalid --env value: "${value}". Use --env=production or --env=staging.`);
process.exit(1);
}
return value;
}
const TARGET_ENV = parseTargetEnv(CLI_ARGS);
const ENV_DEFAULTS = {
production: {
basePath: '/var/www/client-portal-laravel',
workerSupervisorGroup: 'client-portal-worker',
reverbSupervisorGroup: 'client-portal-reverb',
fpmReloadStrategy: 'restart',
domain: process.env.MARKETING_DOMAIN || 'scopeforged.com',
},
staging: {
basePath: '/var/www/client-portal-laravel-staging',
workerSupervisorGroup: 'client-portal-staging-worker',
reverbSupervisorGroup: 'client-portal-staging-reverb',
fpmReloadStrategy: 'reload',
domain: 'staging.scopeforged.com',
},
};
const ENV = ENV_DEFAULTS[TARGET_ENV];
// ==== CONFIGURATION ====
const CONFIG = {
server: {
host: (process.env.SERVER_HOST || '').trim(),
username: (process.env.SERVER_USERNAME || '').trim(),
privateKey: (process.env.SERVER_PRIVATE_KEY || '').trim(),
},
paths: {
// --env=staging makes ENV.basePath authoritative so a prod-tuned
// .env.deployment SERVER_BASE_PATH cannot redirect a staging
// rollback at prod (plan §4.1).
basePath: TARGET_ENV === 'staging'
? ENV.basePath
: (process.env.SERVER_BASE_PATH || ENV.basePath),
releasesDir: 'releases',
currentLink: 'current',
},
};
// ==== UTILITIES ====
function log(emoji, message) {
const timestamp = new Date().toISOString().substring(11, 19);
console.log(`[${timestamp}] ${emoji} ${message}`);
}
async function execSSH(ssh, command, options = {}) {
const result = await ssh.execCommand(command, options);
if (result.code !== 0 && !options.ignoreError) {
throw new Error(`Command failed: ${command}\nStderr: ${result.stderr}\nStdout: ${result.stdout}`);
}
return result;
}
// ==== ROLLBACK FUNCTIONS ====
/**
* Get list of releases (sorted oldest to newest)
*/
async function getReleases(ssh) {
const releasesPath = `${CONFIG.paths.basePath}/${CONFIG.paths.releasesDir}`;
const result = await execSSH(ssh, `ls -1 ${releasesPath} 2>/dev/null | sort`, { ignoreError: true });
if (!result.stdout.trim()) {
return [];
}
return result.stdout.trim().split('\n').filter(r => r && /^\d{14}$/.test(r));
}
/**
* Get current release name from symlink
*/
async function getCurrentRelease(ssh) {
const currentPath = `${CONFIG.paths.basePath}/${CONFIG.paths.currentLink}`;
const result = await execSSH(ssh, `readlink ${currentPath} 2>/dev/null | xargs basename`, { ignoreError: true });
if (result.code !== 0 || !result.stdout.trim()) {
return null;
}
return result.stdout.trim();
}
/**
* Switch current symlink to target release
*/
async function switchToRelease(ssh, releaseName) {
const releasesPath = `${CONFIG.paths.basePath}/${CONFIG.paths.releasesDir}`;
const releasePath = `${releasesPath}/${releaseName}`;
const currentPath = `${CONFIG.paths.basePath}/${CONFIG.paths.currentLink}`;
// Verify release exists
const exists = await execSSH(ssh, `test -d ${releasePath} && echo "yes"`, { ignoreError: true });
if (exists.stdout.trim() !== 'yes') {
throw new Error(`Release ${releaseName} does not exist`);
}
// Switch symlink
await execSSH(ssh, `ln -sfn ${releasePath} ${currentPath}`);
}
/**
* Reload services after rollback
*/
async function reloadServices(ssh) {
// Mirror deploy-linux.cjs FPM strategy: prod restarts (defeats
// symlink-cached config); staging reloads (avoids resetting prod
// OPcache when both pools share a daemon).
if (ENV.fpmReloadStrategy === 'restart') {
log('♻️', 'Restarting php-fpm...');
await execSSH(ssh, 'sudo systemctl restart php8.3-fpm');
} else {
log('♻️', 'Reloading php-fpm gracefully...');
await execSSH(ssh, 'sudo systemctl reload php8.3-fpm');
}
log('♻️', 'Reloading Apache...');
await execSSH(ssh, 'sudo systemctl reload apache2');
log('♻️', `Restarting queue workers (${ENV.workerSupervisorGroup})...`);
await execSSH(ssh, `sudo supervisorctl restart ${ENV.workerSupervisorGroup}:*`, { ignoreError: true });
log('♻️', `Restarting Reverb WebSocket server (${ENV.reverbSupervisorGroup})...`);
await execSSH(ssh, `sudo supervisorctl restart ${ENV.reverbSupervisorGroup}`, { ignoreError: true });
log('✅', 'Services reloaded');
}
/**
* Clear caches on the target release
*/
async function clearCaches(ssh, releasePath) {
log('🧹', 'Clearing caches...');
await execSSH(ssh, `cd ${releasePath} && php artisan config:clear`, { ignoreError: true });
await execSSH(ssh, `cd ${releasePath} && php artisan config:cache`, { ignoreError: true });
await execSSH(ssh, `cd ${releasePath} && php artisan route:cache`, { ignoreError: true });
await execSSH(ssh, `cd ${releasePath} && php artisan view:cache`, { ignoreError: true });
log('✅', 'Caches cleared');
}
// ==== SENTRY RELEASE TRACKING ====
/**
* Update SENTRY_RELEASE in the shared .env on the server.
*/
async function updateSentryRelease(ssh, releaseName) {
if (!/^\d{14}$/.test(releaseName)) {
log('⚠️', `Skipping Sentry release update: invalid release name "${releaseName}"`);
return;
}
const envFile = `${CONFIG.paths.basePath}/shared/.env`;
log('📝', `Setting SENTRY_RELEASE=${releaseName} in shared .env...`);
// sudo + re-chown rationale — same as deploy-linux.cjs::updateSentryRelease.
const result = await execSSH(ssh, `sudo grep -q '^SENTRY_RELEASE=' ${envFile} && echo "exists" || echo "missing"`, { ignoreError: true });
if (result.stdout.trim() === 'exists') {
await execSSH(ssh, `sudo sed -i 's/^SENTRY_RELEASE=.*/SENTRY_RELEASE=${releaseName}/' ${envFile}`);
} else {
await execSSH(ssh, `echo 'SENTRY_RELEASE=${releaseName}' | sudo tee -a ${envFile} >/dev/null`);
}
// Restore canonical ubuntu:www-data ownership (sudo sed flips it).
// Rationale lives in scripts/deploy/deploy-linux.cjs::setupSharedDirectory.
await execSSH(ssh, `sudo chown ubuntu:www-data ${envFile}`);
await execSSH(ssh, `sudo chmod 0640 ${envFile}`);
log('✅', 'SENTRY_RELEASE updated in shared .env');
}
/**
* Register a Sentry deploy for the rolled-back release.
* Gated behind SENTRY_AUTH_TOKEN — skips gracefully if not configured.
*/
function registerSentryDeploy(releaseName) {
if (!/^\d{14}$/.test(releaseName)) {
return;
}
if (!process.env.SENTRY_AUTH_TOKEN) {
log('⚠️', 'Skipping Sentry deploy registration: SENTRY_AUTH_TOKEN not set');
return;
}
const org = process.env.SENTRY_ORG;
const project = process.env.SENTRY_PROJECT;
if (!org || !project) {
log('⚠️', 'Skipping Sentry deploy registration: SENTRY_ORG or SENTRY_PROJECT not set');
return;
}
log('📡', `Registering Sentry deploy for release: ${releaseName}...`);
const sentryCliPath = path.join(PROJECT_ROOT, 'node_modules', '.bin', 'sentry-cli');
const env = {
...process.env,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
SENTRY_ORG: org,
SENTRY_PROJECT: project,
};
try {
execSync(`"${sentryCliPath}" deploys new -r ${releaseName} -e production`, { stdio: 'pipe', env });
log('✅', 'Sentry deploy registered');
} catch (error) {
const stderr = error.stderr ? error.stderr.toString().trim() : error.message;
log('⚠️', `Sentry deploy registration failed (non-fatal): ${stderr}`);
}
}
// ==== MAIN ====
async function rollback(options = {}) {
const ssh = new NodeSSH();
try {
// Validate configuration
if (!CONFIG.server.host || !CONFIG.server.username || !CONFIG.server.privateKey) {
throw new Error('Missing required environment variables: SERVER_HOST, SERVER_USERNAME, SERVER_PRIVATE_KEY');
}
if (!fs.existsSync(CONFIG.server.privateKey)) {
throw new Error(`Private key file not found: ${CONFIG.server.privateKey}`);
}
// Connect to server
log('🔌', 'Connecting to server...');
const privateKeyContent = fs.readFileSync(CONFIG.server.privateKey, 'utf8');
await ssh.connect({
host: CONFIG.server.host,
username: CONFIG.server.username,
privateKey: privateKeyContent,
});
log('✅', 'Connected to server');
// Get releases and current release
const releases = await getReleases(ssh);
const currentRelease = await getCurrentRelease(ssh);
if (releases.length === 0) {
throw new Error('No releases found on server');
}
log('📋', `Found ${releases.length} release(s)`);
log('📍', `Current release: ${currentRelease || 'none'}`);
// Determine target release
let targetRelease;
if (options.toRelease) {
// Roll back to specific release
targetRelease = options.toRelease;
if (!releases.includes(targetRelease)) {
throw new Error(`Release ${targetRelease} not found. Available: ${releases.join(', ')}`);
}
} else {
// Roll back N releases
const currentIndex = releases.indexOf(currentRelease);
if (currentIndex === -1) {
throw new Error('Current release not found in releases list');
}
const targetIndex = currentIndex - options.releases;
if (targetIndex < 0) {
throw new Error(`Cannot roll back ${options.releases} release(s). Only ${currentIndex} previous release(s) available.`);
}
targetRelease = releases[targetIndex];
}
log('🎯', `Target release: ${targetRelease}`);
// Dry run check
if (options.dryRun) {
log('📝', 'DRY RUN - Would perform:');
console.log(` Switch symlink: current -> releases/${targetRelease}`);
console.log(' Clear caches');
console.log(' Reload Apache');
console.log(' Restart queue workers');
console.log(' Restart Reverb');
process.exit(0);
}
// Confirm rollback
if (targetRelease === currentRelease) {
log('⚠️', 'Target release is same as current release. Nothing to do.');
process.exit(0);
}
// Perform rollback
log('🔄', 'Rolling back...');
await switchToRelease(ssh, targetRelease);
log('✅', `Symlink switched to ${targetRelease}`);
// Update SENTRY_RELEASE in shared .env
await updateSentryRelease(ssh, targetRelease);
// Clear caches on the target release
const releasesPath = `${CONFIG.paths.basePath}/${CONFIG.paths.releasesDir}`;
await clearCaches(ssh, `${releasesPath}/${targetRelease}`);
// Reload services
await reloadServices(ssh);
// Register Sentry deploy for the rolled-back release
registerSentryDeploy(targetRelease);
// Done
log('🎉', `Rollback complete! Now running: ${targetRelease} (${TARGET_ENV})`);
log('🌐', `Site: https://${ENV.domain}`);
} catch (error) {
log('❌', `Rollback failed: ${error.message}`);
process.exit(1);
} finally {
ssh.dispose();
}
}
// ==== PARSE ARGUMENTS ====
const args = CLI_ARGS;
// Show help
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Laravel Client Portal - Rollback Script
Usage: node scripts/deploy/rollback.cjs [options]
Target selection (plan §4.1):
--env=production Roll back the production deploy (default)
--env=staging Roll back the staging deploy at staging.scopeforged.com
Options:
--releases=N Number of releases to roll back (default: 1)
--to=TIMESTAMP Roll back to a specific release by name
--dry-run Show what would happen without making changes
--help, -h Show this help message
Target environment: ${TARGET_ENV}
Examples:
node scripts/deploy/rollback.cjs Roll back 1 prod release
node scripts/deploy/rollback.cjs --env=staging Roll back 1 staging release
node scripts/deploy/rollback.cjs --releases=2 Roll back 2 releases
node scripts/deploy/rollback.cjs --to=20260115120000 Roll back to specific release
node scripts/deploy/rollback.cjs --dry-run Preview rollback
`);
process.exit(0);
}
// Parse options
const options = {
releases: 1,
toRelease: null,
dryRun: args.includes('--dry-run'),
};
for (const arg of args) {
if (arg.startsWith('--releases=')) {
options.releases = parseInt(arg.split('=')[1], 10);
if (isNaN(options.releases) || options.releases < 1) {
console.error('❌ --releases must be a positive integer');
process.exit(1);
}
}
if (arg.startsWith('--to=')) {
options.toRelease = arg.split('=')[1];
if (!/^\d{14}$/.test(options.toRelease)) {
console.error('❌ --to must be a valid release name (YYYYMMDDHHMMSS)');
process.exit(1);
}
}
}
rollback(options);