From e09444970a16c533a91bd722adc2dd706861f6ce Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 19 May 2026 20:00:05 +0000 Subject: [PATCH] feat(deploy): add sync-joomla.php for server-to-server Joomla sync New CLI script that syncs Joomla directories between two servers via rsync over SSH relay. Always excludes configuration.php. Authored-by: Moko Consulting --- deploy/sync-joomla.php | 453 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 deploy/sync-joomla.php diff --git a/deploy/sync-joomla.php b/deploy/sync-joomla.php new file mode 100644 index 0000000..a3d64e2 --- /dev/null +++ b/deploy/sync-joomla.php @@ -0,0 +1,453 @@ +#!/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/sync-joomla.php + * VERSION: 01.00.00 + * BRIEF: Sync Joomla site directories between two servers via rsync over SSH + */ + +declare(strict_types=1); + +class SyncJoomla +{ + /** @var string Path to source sftp-config.json */ + private string $sourceConfig = ''; + + /** @var string Path to dest sftp-config.json */ + private string $destConfig = ''; + + /** @var bool Sync standard Joomla directories only */ + private bool $rsyncMode = false; + + /** @var bool Sync everything under remote_path */ + private bool $fullMode = false; + + /** @var bool Dry-run (preview only) */ + private bool $dryRun = false; + + /** @var bool Verbose output */ + private bool $verbose = false; + + /** @var string[] Additional exclude patterns */ + private array $excludes = []; + + /** @var string Local relay directory */ + private string $relayDir = '/tmp/sync/'; + + /** @var string[] Standard Joomla directories to sync */ + private array $joomlaDirs = [ + 'administrator/components', + 'administrator/language', + 'administrator/modules', + 'administrator/templates', + 'components', + 'language', + 'layouts', + 'libraries', + 'media', + 'modules', + 'plugins', + 'templates', + ]; + + /** + * Main entry point. + * + * @return int Exit code + */ + public function run(): int + { + $this->parseArgs(); + + if (!$this->validate()) { + return 1; + } + + $source = $this->loadConfig($this->sourceConfig); + $dest = $this->loadConfig($this->destConfig); + + if ($source === null || $dest === null) { + return 1; + } + + $this->log("Source: {$source['user']}@{$source['host']}:{$source['remote_path']}"); + $this->log("Dest: {$dest['user']}@{$dest['host']}:{$dest['remote_path']}"); + + if ($this->dryRun) { + $this->log('[DRY-RUN] No files will be transferred.'); + } + + $this->prepareRelayDir(); + + $dirs = $this->resolveDirs(); + $totalFiles = 0; + $syncedDirs = 0; + + foreach ($dirs as $dir) { + $this->log("--- Syncing: {$dir}"); + + $pulled = $this->pullFromSource($source, $dir); + if ($pulled === false) { + $this->log(" WARNING: pull failed for {$dir}, skipping."); + continue; + } + + $pushed = $this->pushToDest($dest, $dir); + if ($pushed === false) { + $this->log(" WARNING: push failed for {$dir}, skipping."); + continue; + } + + $totalFiles += $pulled + $pushed; + $syncedDirs++; + } + + $this->cleanup(); + $this->log(''); + $this->log('=== Sync Summary ==='); + $this->log("Directories synced: {$syncedDirs}/" . count($dirs)); + $this->log("Rsync operations: " . ($syncedDirs * 2) . " (pull + push)"); + + if ($this->dryRun) { + $this->log('Mode: dry-run (no files were transferred)'); + } + + return 0; + } + + /** + * Parse command-line arguments. + */ + private function parseArgs(): void + { + global $argv; + + $i = 1; + while ($i < count($argv)) { + switch ($argv[$i]) { + case '--source': + $this->sourceConfig = $argv[++$i] ?? ''; + break; + case '--dest': + $this->destConfig = $argv[++$i] ?? ''; + break; + case '--rsync': + $this->rsyncMode = true; + break; + case '--full': + $this->fullMode = true; + break; + case '--dry-run': + $this->dryRun = true; + break; + case '--verbose': + $this->verbose = true; + break; + case '--exclude': + $this->excludes[] = $argv[++$i] ?? ''; + break; + default: + $this->log("Unknown argument: {$argv[$i]}"); + break; + } + $i++; + } + } + + /** + * Validate required arguments. + * + * @return bool True if valid + */ + private function validate(): bool + { + if ($this->sourceConfig === '' || $this->destConfig === '') { + $this->log('ERROR: --source and --dest are required.'); + $this->printUsage(); + return false; + } + + if (!$this->rsyncMode && !$this->fullMode) { + $this->log('ERROR: Either --rsync or --full must be specified.'); + $this->printUsage(); + return false; + } + + if ($this->rsyncMode && $this->fullMode) { + $this->log('ERROR: --rsync and --full are mutually exclusive.'); + return false; + } + + if (!file_exists($this->sourceConfig)) { + $this->log("ERROR: Source config not found: {$this->sourceConfig}"); + return false; + } + + if (!file_exists($this->destConfig)) { + $this->log("ERROR: Dest config not found: {$this->destConfig}"); + return false; + } + + return true; + } + + /** + * Load and decode an sftp-config.json file. + * + * @param string $path Path to the config file + * @return array|null Parsed config or null on error + */ + private function loadConfig(string $path): ?array + { + $json = file_get_contents($path); + if ($json === false) { + $this->log("ERROR: Cannot read config: {$path}"); + return null; + } + + // Strip // comments (Sublime Text SFTP format) + $json = preg_replace('#^\s*//.*$#m', '', $json); + $json = preg_replace('#,\s*([\]}])#', '$1', $json); + + $config = json_decode($json, true); + if (!is_array($config)) { + $this->log("ERROR: Invalid JSON in config: {$path}"); + return null; + } + + $required = ['host', 'user', 'remote_path', 'ssh_key_file']; + foreach ($required as $key) { + if (empty($config[$key])) { + $this->log("ERROR: Missing '{$key}' in config: {$path}"); + return null; + } + } + + if (!isset($config['port'])) { + $config['port'] = 22; + } + + return $config; + } + + /** + * Resolve the list of directories to sync. + * + * @return string[] Directory paths (relative to remote_path) + */ + private function resolveDirs(): array + { + if ($this->fullMode) { + return ['.']; + } + + return $this->joomlaDirs; + } + + /** + * Prepare the local relay directory. + */ + private function prepareRelayDir(): void + { + if (is_dir($this->relayDir)) { + shell_exec("rm -rf " . escapeshellarg($this->relayDir)); + } + + mkdir($this->relayDir, 0755, true); + $this->log("Relay directory: {$this->relayDir}"); + } + + /** + * Build common rsync exclude flags. + * + * configuration.php is always excluded — it contains per-environment + * database credentials and settings that must never be synced. + * + * @return string Exclude arguments for rsync + */ + private function buildExcludes(): string + { + $excludes = ['configuration.php']; + $excludes = array_merge($excludes, $this->excludes); + + $flags = ''; + foreach ($excludes as $pattern) { + $flags .= ' --exclude=' . escapeshellarg($pattern); + } + + return $flags; + } + + /** + * Build SSH command fragment for rsync. + * + * @param array $config Server config + * @return string The -e flag value for rsync + */ + private function buildSshCmd(array $config): string + { + $keyPath = escapeshellarg($config['ssh_key_file']); + $port = (int) $config['port']; + + return "ssh -i {$keyPath} -p {$port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; + } + + /** + * Pull a directory from the source server to the local relay. + * + * @param array $config Source server config + * @param string $dir Relative directory to sync + * @return int|false Number of files or false on failure + */ + private function pullFromSource(array $config, string $dir): int|false + { + $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); + $localPath = $this->relayDir . ltrim($dir, './'); + + if (!is_dir($localPath)) { + mkdir($localPath, 0755, true); + } + + $sshCmd = $this->buildSshCmd($config); + $excludes = $this->buildExcludes(); + $dryFlag = $this->dryRun ? ' --dry-run' : ''; + $verboseFlag = $this->verbose ? ' -v' : ''; + + $remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/"); + $local = escapeshellarg("{$localPath}/"); + + $cmd = "rsync -az --delete" + . $dryFlag + . $verboseFlag + . $excludes + . " -e " . escapeshellarg($sshCmd) + . " {$remote} {$local}" + . " 2>&1"; + + $this->logVerbose(" PULL: {$cmd}"); + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); + return false; + } + + $fileCount = count($output); + $this->logVerbose(" Pulled {$fileCount} line(s) of output."); + + return $fileCount; + } + + /** + * Push a directory from the local relay to the destination server. + * + * @param array $config Dest server config + * @param string $dir Relative directory to sync + * @return int|false Number of files or false on failure + */ + private function pushToDest(array $config, string $dir): int|false + { + $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); + $localPath = $this->relayDir . ltrim($dir, './'); + + $sshCmd = $this->buildSshCmd($config); + $excludes = $this->buildExcludes(); + $dryFlag = $this->dryRun ? ' --dry-run' : ''; + $verboseFlag = $this->verbose ? ' -v' : ''; + + $local = escapeshellarg("{$localPath}/"); + $remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/"); + + $cmd = "rsync -az --delete" + . $dryFlag + . $verboseFlag + . $excludes + . " -e " . escapeshellarg($sshCmd) + . " {$local} {$remote}" + . " 2>&1"; + + $this->logVerbose(" PUSH: {$cmd}"); + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); + return false; + } + + $fileCount = count($output); + $this->logVerbose(" Pushed {$fileCount} line(s) of output."); + + return $fileCount; + } + + /** + * Clean up the relay directory. + */ + private function cleanup(): void + { + if (is_dir($this->relayDir)) { + shell_exec("rm -rf " . escapeshellarg($this->relayDir)); + $this->logVerbose("Cleaned up relay directory."); + } + } + + /** + * Print usage information. + */ + private function printUsage(): void + { + $this->log(''); + $this->log('Usage: sync-joomla.php --source --dest [--rsync|--full] [options]'); + $this->log(''); + $this->log('Required:'); + $this->log(' --source sftp-config.json for source server'); + $this->log(' --dest sftp-config.json for dest server'); + $this->log(' --rsync Sync standard Joomla directories'); + $this->log(' --full Sync everything under the remote path'); + $this->log(''); + $this->log('Options:'); + $this->log(' --dry-run Preview only, no files transferred'); + $this->log(' --verbose Verbose output'); + $this->log(' --exclude Additional exclude pattern (repeatable)'); + } + + /** + * Log a message to stdout. + * + * @param string $message Message to log + */ + private function log(string $message): void + { + echo $message . PHP_EOL; + } + + /** + * Log a verbose message (only when --verbose is set). + * + * @param string $message Message to log + */ + private function logVerbose(string $message): void + { + if ($this->verbose) { + $this->log($message); + } + } +} + +$sync = new SyncJoomla(); +exit($sync->run()); -- 2.52.0