diff --git a/deploy/rollback-joomla.php b/deploy/rollback-joomla.php new file mode 100644 index 0000000..e8d404d --- /dev/null +++ b/deploy/rollback-joomla.php @@ -0,0 +1,230 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Deploy + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /deploy/rollback-joomla.php + * VERSION: 01.00.00 + * BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot + */ + +declare(strict_types=1); + +class RollbackJoomla +{ + private bool $verbose = false; + private bool $dryRun = false; + private string $configPath = ''; + private string $snapshotDir = ''; + + private const JOOMLA_DIRS = [ + 'administrator/components', + 'administrator/language', + 'administrator/modules', + 'administrator/templates', + 'components', + 'language', + 'layouts', + 'libraries', + 'media', + 'modules', + 'plugins', + 'templates', + ]; + + public function run(): int + { + $this->parseArgs(); + + if ($this->configPath === '' || $this->snapshotDir === '') { + $this->log('Usage: rollback-joomla.php --config --snapshot-dir [--dry-run] [--verbose]'); + return 1; + } + + if (!is_dir($this->snapshotDir)) { + $this->log("ERROR: Snapshot directory does not exist: {$this->snapshotDir}"); + return 1; + } + + $config = $this->loadConfig($this->configPath); + if ($config === null) { + return 1; + } + + $host = $config['host'] ?? ''; + $user = $config['user'] ?? ''; + $port = (int) ($config['port'] ?? 22); + $remotePath = rtrim($config['remote_path'] ?? '', '/'); + $sshKey = $config['ssh_key_file'] ?? ''; + + if ($host === '' || $user === '' || $remotePath === '') { + $this->log('ERROR: Config must contain host, user, and remote_path.'); + return 1; + } + + $this->log('Starting Joomla rollback from snapshot...'); + $this->log("Snapshot: {$this->snapshotDir}"); + $this->log("Target: {$user}@{$host}:{$remotePath}"); + + if ($this->dryRun) { + $this->log('*** DRY RUN — no changes will be made ***'); + } + + $failed = 0; + + foreach (self::JOOMLA_DIRS as $dir) { + $localDir = rtrim($this->snapshotDir, '/\\') . '/' . $dir . '/'; + + if (!is_dir($localDir)) { + if ($this->verbose) { + $this->log("SKIP: {$dir} (not present in snapshot)"); + } + continue; + } + + $remoteTarget = "{$remotePath}/{$dir}/"; + $sshCmd = "ssh -p {$port}"; + if ($sshKey !== '') { + $sshCmd .= " -i " . escapeshellarg($sshKey); + } + + $rsyncArgs = [ + 'rsync', + '-rlptz', + '--delete', + '--exclude=configuration.php', + '-e', $sshCmd, + ]; + + if ($this->dryRun) { + $rsyncArgs[] = '--dry-run'; + } + + if ($this->verbose) { + $rsyncArgs[] = '-v'; + } + + $rsyncArgs[] = $localDir; + $rsyncArgs[] = "{$user}@{$host}:{$remoteTarget}"; + + $cmd = implode(' ', array_map('escapeshellarg', $rsyncArgs)); + // rsync -e needs unescaped, rebuild manually + $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); + + $this->log("Restoring: {$dir}"); + if ($this->verbose) { + $this->log("CMD: {$cmd}"); + } + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})"); + foreach ($output as $line) { + $this->log(" {$line}"); + } + $failed++; + } else { + if ($this->verbose) { + foreach ($output as $line) { + $this->log(" {$line}"); + } + } + } + } + + if ($failed > 0) { + $this->log("Rollback completed with {$failed} error(s)."); + return 1; + } + + $this->log('Rollback completed successfully.'); + return 0; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) { + switch ($args[$i]) { + case '--config': + $this->configPath = $args[++$i] ?? ''; + break; + case '--snapshot-dir': + $this->snapshotDir = $args[++$i] ?? ''; + break; + case '--dry-run': + $this->dryRun = true; + break; + case '--verbose': + $this->verbose = true; + break; + } + } + } + + private function loadConfig(string $path): ?array + { + if (!is_file($path)) { + $this->log("ERROR: Config file not found: {$path}"); + return null; + } + + $raw = file_get_contents($path); + if ($raw === false) { + $this->log("ERROR: Could not read config file: {$path}"); + return null; + } + + // Strip // comments (sftp-config.json style) + $cleaned = preg_replace('#^\s*//.*$#m', '', $raw); + $config = json_decode($cleaned, true); + + if (!is_array($config)) { + $this->log('ERROR: Invalid JSON in config file.'); + return null; + } + + return $config; + } + + private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string + { + $parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php']; + + if ($this->dryRun) { + $parts[] = '--dry-run'; + } + + if ($this->verbose) { + $parts[] = '-v'; + } + + $parts[] = '-e'; + $parts[] = escapeshellarg($sshCmd); + $parts[] = escapeshellarg($source); + $parts[] = escapeshellarg($dest); + + return implode(' ', $parts); + } + + private function log(string $message): void + { + $timestamp = date('Y-m-d H:i:s'); + fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); + } +} + +$app = new RollbackJoomla(); +exit($app->run());