Files
moko-platform/deploy/sync-joomla.php
Jonathan Miller 6a29fbd99e refactor: rename GiteaAdapter to MokoGiteaAdapter
Rename class, file, and all references across the codebase to align
with the moko-platform naming convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:14:29 -05:00

454 lines
11 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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 <config> --dest <config> [--rsync|--full] [options]');
$this->log('');
$this->log('Required:');
$this->log(' --source <path> sftp-config.json for source server');
$this->log(' --dest <path> 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 <pattern> 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());