2026-06-20 23:31:08 -05:00
|
|
|
#!/usr/bin/env php
|
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 23:40:54 -05:00
|
|
|
$this->audit('start', ['path' => $path, 'env' => $env, 'url' => parse_url($url, PHP_URL_HOST) ?? $url]);
|
2026-06-20 23:31:08 -05:00
|
|
|
|
|
|
|
|
// ── Build subprocess args ────────────────────────────────────
|
|
|
|
|
$deployArgs = $this->buildDeployArgs($path, $env, $config);
|
|
|
|
|
|
|
|
|
|
// ── Step 1: Backup ───────────────────────────────────────────
|
|
|
|
|
$this->section('Step 1: Pre-deploy backup');
|
2026-06-20 23:40:54 -05:00
|
|
|
$snapshotDir = sys_get_temp_dir() . '/moko_deploy_snapshot_' . date('Ymd_His') . '_' . getmypid() . '_' . bin2hex(random_bytes(4));
|
2026-06-20 23:31:08 -05:00
|
|
|
|
|
|
|
|
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) {
|
2026-06-20 23:40:54 -05:00
|
|
|
$this->log('ERROR', 'Deploy failed — rolling back to pre-deploy state');
|
2026-06-20 23:31:08 -05:00
|
|
|
$this->audit('deploy_failed', ['exit_code' => $deployExit]);
|
2026-06-20 23:40:54 -05:00
|
|
|
$this->runSubprocess('rollback-joomla.php', array_merge(
|
|
|
|
|
$deployArgs, ['--snapshot-dir', $snapshotDir]
|
|
|
|
|
));
|
2026-06-20 23:31:08 -05:00
|
|
|
$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)) {
|
2026-06-20 23:40:54 -05:00
|
|
|
$this->removeDirectory($snapshotDir);
|
2026-06-20 23:31:08 -05:00
|
|
|
$this->log('DEBUG', "Cleaned up snapshot: {$snapshotDir}");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-20 23:40:54 -05:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-06-20 23:31:08 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-20 23:40:54 -05:00
|
|
|
$app = new DeployAndVerify();
|
2026-06-20 23:31:08 -05:00
|
|
|
exit($app->execute());
|