From 46e33a93834ebe09680f3594b47a37b5459038dc Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 20 Jun 2026 23:31:08 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20deploy:verify=20=E2=80=94=20deploy=20wi?= =?UTF-8?q?th=20auto=20health=20check=20and=20rollback=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrates backup → deploy → health-check → rollback-if-failed: - Pre-deploy snapshot via backup-before-deploy.php - Deploy via deploy-sftp.php subprocess - Inline health check with configurable retries and delay - Auto-rollback via rollback-joomla.php if health check fails - Post-rollback verification - Full audit trail via AuditLogger --- bin/moko | 1 + deploy/deploy-and-verify.php | 331 +++++++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 deploy/deploy-and-verify.php diff --git a/bin/moko b/bin/moko index b6a58b6..dd449b8 100644 --- a/bin/moko +++ b/bin/moko @@ -199,6 +199,7 @@ const COMMAND_MAP = [ 'deploy:sftp' => 'deploy/deploy-sftp.php', 'deploy:backup' => 'deploy/backup-before-deploy.php', 'deploy:health-check' => 'deploy/health-check.php', + 'deploy:verify' => 'deploy/deploy-and-verify.php', 'deploy:rollback' => 'deploy/rollback-joomla.php', 'deploy:sync' => 'deploy/sync-joomla.php', diff --git a/deploy/deploy-and-verify.php b/deploy/deploy-and-verify.php new file mode 100644 index 0000000..584f4a6 --- /dev/null +++ b/deploy/deploy-and-verify.php @@ -0,0 +1,331 @@ +#!/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' => $url]); + + // ── Build subprocess args ──────────────────────────────────── + $deployArgs = $this->buildDeployArgs($path, $env, $config); + $healthArgs = $this->buildHealthArgs($url, $checks, $timeout); + + // ── Step 1: Backup ─────────────────────────────────────────── + $this->section('Step 1: Pre-deploy backup'); + $snapshotDir = sys_get_temp_dir() . '/moko_deploy_snapshot_' . date('Ymd_His') . '_' . getmypid(); + + 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 — no rollback needed (files unchanged)'); + $this->audit('deploy_failed', ['exit_code' => $deployExit]); + $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; + } + + private function buildHealthArgs(string $url, string $checks, int $timeout): array + { + return ['--url', $url, '--checks', $checks, '--timeout', (string) $timeout]; + } + + // ── 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)) { + exec(sprintf('rm -rf %s', escapeshellarg($snapshotDir))); + $this->log('DEBUG', "Cleaned up snapshot: {$snapshotDir}"); + } + } +} + +$app = new DeployAndVerify('deploy_and_verify', 'Deploy with automatic health check and rollback'); +exit($app->execute());