6a29fbd99e
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>
454 lines
11 KiB
PHP
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());
|