#!/usr/bin/env php * SPDX-License-Identifier: GPL-3.0-or-later * FILE INFORMATION * DEFGROUP: MokoPlatform.Scripts.Deploy * INGROUP: MokoPlatform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * PATH: /deploy/deploy-and-verify.php * BRIEF: Deploy with automatic health check and rollback on failure */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoCli\{AuditLogger, CliFramework}; /** * Deploy-and-Verify: orchestrates backup → deploy → health-check → rollback. * * If the health check fails after deployment, automatically triggers a rollback * using the pre-deploy snapshot, with full audit trail. * * @see https://git.mokoconsulting.tech/MokoConsulting/mokocli/issues/147 */ class DeployAndVerify extends CliFramework { private ?AuditLogger $auditLogger = null; protected function configure(): void { $this->setDescription('Deploy with automatic health check and rollback on failure'); $this->addArgument('--path', 'Repository root', '.'); $this->addArgument('--env', 'Target environment: dev, demo, rs, live', ''); $this->addArgument('--config', 'Explicit sftp-config path (overrides --env)', ''); $this->addArgument('--url', 'Site URL for health check', ''); $this->addArgument('--checks', 'Health checks: http,admin,api (comma-sep)', 'http'); $this->addArgument('--timeout', 'Health check timeout in seconds', '30'); $this->addArgument('--retries', 'Health check retries before rollback', '2'); $this->addArgument('--delay', 'Seconds between health check retries', '5'); } protected function run(): int { $path = realpath($this->getArgument('--path', '.')) ?: '.'; $env = $this->getArgument('--env', ''); $config = $this->getArgument('--config', ''); $url = $this->getArgument('--url', ''); $checks = $this->getArgument('--checks', 'http'); $timeout = (int) $this->getArgument('--timeout', '30'); $retries = (int) $this->getArgument('--retries', '2'); $delay = (int) $this->getArgument('--delay', '5'); if ($url === '') { $this->log('ERROR', 'The --url argument is required for health checks'); return self::EXIT_USAGE; } if ($env === '' && $config === '') { $this->log('ERROR', 'Specify --env or --config for the deploy target'); return self::EXIT_USAGE; } try { $this->auditLogger = new AuditLogger('deploy-and-verify'); } catch (\Exception $e) { // Non-fatal — proceed without audit logging } $this->audit('start', ['path' => $path, 'env' => $env, 'url' => parse_url($url, PHP_URL_HOST) ?? $url]); // ── Build subprocess args ──────────────────────────────────── $deployArgs = $this->buildDeployArgs($path, $env, $config); // ── Step 1: Backup ─────────────────────────────────────────── $this->section('Step 1: Pre-deploy backup'); $snapshotDir = sys_get_temp_dir() . '/moko_deploy_snapshot_' . date('Ymd_His') . '_' . getmypid() . '_' . bin2hex(random_bytes(4)); if ($this->dryRun) { $this->log('INFO', "[dry-run] Would create snapshot at {$snapshotDir}"); } else { $backupExit = $this->runSubprocess('backup-before-deploy.php', array_merge( $deployArgs, ['--snapshot-dir', $snapshotDir] )); if ($backupExit !== 0) { $this->log('ERROR', 'Pre-deploy backup failed — aborting deployment'); $this->audit('backup_failed', ['exit_code' => $backupExit]); return self::EXIT_FAILURE; } $this->log('INFO', "Snapshot saved to {$snapshotDir}"); } // ── Step 2: Deploy ─────────────────────────────────────────── $this->section('Step 2: Deploy'); if ($this->dryRun) { $this->log('INFO', '[dry-run] Would run deploy-sftp.php ' . implode(' ', $deployArgs)); } else { $deployExit = $this->runSubprocess('deploy-sftp.php', $deployArgs); if ($deployExit !== 0) { $this->log('ERROR', 'Deploy failed — rolling back to pre-deploy state'); $this->audit('deploy_failed', ['exit_code' => $deployExit]); $this->runSubprocess('rollback-joomla.php', array_merge( $deployArgs, ['--snapshot-dir', $snapshotDir] )); $this->cleanup($snapshotDir); return self::EXIT_FAILURE; } $this->log('INFO', 'Deploy completed successfully'); } // ── Step 3: Health check (with retries) ────────────────────── $this->section('Step 3: Health check'); if ($this->dryRun) { $this->log('INFO', "[dry-run] Would check {$url} with checks: {$checks}"); $this->log('INFO', '[dry-run] Deploy-and-verify complete'); return self::EXIT_SUCCESS; } $healthy = false; for ($attempt = 1; $attempt <= $retries; $attempt++) { $this->log('INFO', "Health check attempt {$attempt}/{$retries}..."); if ($attempt > 1) { $this->log('INFO', "Waiting {$delay}s before retry..."); sleep($delay); } $healthExit = $this->runHealthCheck($url, $checks, $timeout); if ($healthExit === 0) { $healthy = true; break; } $this->log('WARNING', "Health check attempt {$attempt} failed (exit {$healthExit})"); } if ($healthy) { $this->section('Result: SUCCESS'); $this->log('INFO', 'Health check passed — deploy verified'); $this->audit('success', ['url' => $url, 'attempts' => $attempt]); $this->cleanup($snapshotDir); return self::EXIT_SUCCESS; } // ── Step 4: Rollback ───────────────────────────────────────── $this->section('Step 4: ROLLBACK'); $this->log('ERROR', "Health check failed after {$retries} attempts — rolling back"); $this->audit('rollback_triggered', ['url' => $url, 'retries' => $retries]); $rollbackExit = $this->runSubprocess('rollback-joomla.php', array_merge( $deployArgs, ['--snapshot-dir', $snapshotDir] )); if ($rollbackExit === 0) { $this->log('INFO', 'Rollback completed — site restored to pre-deploy state'); $this->audit('rollback_success', []); // Verify rollback worked $postRollbackHealth = $this->runHealthCheck($url, $checks, $timeout); if ($postRollbackHealth === 0) { $this->log('INFO', 'Post-rollback health check passed — site is healthy'); } else { $this->log('ERROR', 'Post-rollback health check FAILED — manual intervention needed'); $this->audit('rollback_verification_failed', []); } } else { $this->log('ERROR', 'Rollback FAILED — manual intervention required'); $this->audit('rollback_failed', ['exit_code' => $rollbackExit]); } $this->cleanup($snapshotDir); return self::EXIT_FAILURE; } // ── Health check (inline, no subprocess) ───────────────────────── private function runHealthCheck(string $url, string $checks, int $timeout): int { $url = rtrim($url, '/'); $checkList = array_map('trim', explode(',', $checks)); $failed = 0; foreach ($checkList as $check) { $checkUrl = match ($check) { 'admin' => $url . '/administrator/', 'api' => $url . '/api/index.php/v1', default => $url, }; $result = $this->httpGet($checkUrl, $timeout); if ($result === null) { $this->log('ERROR', " [{$check}] FAIL: connection failed"); $failed++; continue; } $validCodes = ($check === 'api') ? [200, 401] : [200]; if (!in_array($result['http_code'], $validCodes, true)) { $this->log('ERROR', " [{$check}] FAIL: HTTP {$result['http_code']}"); $failed++; continue; } if ($this->containsFatalError($result['body'])) { $this->log('ERROR', " [{$check}] FAIL: PHP fatal error in response"); $failed++; continue; } $this->log('INFO', " [{$check}] PASS: HTTP {$result['http_code']} ({$result['time_ms']}ms)"); } return $failed > 0 ? 1 : 0; } private function httpGet(string $url, int $timeout): ?array { $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => $timeout, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_USERAGENT => 'MokoDeployVerify/1.0', ]); $body = curl_exec($ch); if (curl_errno($ch)) { curl_close($ch); return null; } $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME); curl_close($ch); return [ 'http_code' => $httpCode, 'body' => is_string($body) ? $body : '', 'time_ms' => (int) round($totalTime * 1000), ]; } private function containsFatalError(string $body): bool { foreach (['Fatal error:', 'Fatal Error', 'Parse error:', 'Uncaught Error:', 'Uncaught Exception:'] as $pattern) { if (stripos($body, $pattern) !== false) { return true; } } return false; } // ── Subprocess helpers ─────────────────────────────────────────── private function runSubprocess(string $script, array $args): int { $scriptPath = __DIR__ . '/' . $script; if (!is_file($scriptPath)) { $this->log('ERROR', "Script not found: {$scriptPath}"); return 127; } $cmd = sprintf('php %s %s 2>&1', escapeshellarg($scriptPath), implode(' ', array_map('escapeshellarg', $args)) ); $this->log('DEBUG', "Running: {$cmd}"); passthru($cmd, $exitCode); return $exitCode; } private function buildDeployArgs(string $path, string $env, string $config): array { $args = ['--path', $path]; if ($config !== '') { $args[] = '--config'; $args[] = $config; } elseif ($env !== '') { $args[] = '--env'; $args[] = $env; } if ($this->dryRun) { $args[] = '--dry-run'; } return $args; } // ── Audit ──────────────────────────────────────────────────────── private function audit(string $event, array $data): void { if ($this->auditLogger === null) { return; } try { $this->auditLogger->logInfo("deploy-verify:{$event}", $data); } catch (\Exception $e) { // Non-fatal } } // ── Cleanup ────────────────────────────────────────────────────── private function cleanup(string $snapshotDir): void { if (is_dir($snapshotDir)) { $this->removeDirectory($snapshotDir); $this->log('DEBUG', "Cleaned up snapshot: {$snapshotDir}"); } } private function removeDirectory(string $dir): void { $entries = scandir($dir); if ($entries === false) { return; } foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $path = $dir . DIRECTORY_SEPARATOR . $entry; is_dir($path) ? $this->removeDirectory($path) : unlink($path); } rmdir($dir); } } $app = new DeployAndVerify(); exit($app->execute());