#!/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());