chore: cascade main → dev (0962252) [skip ci]
#15
@@ -0,0 +1,453 @@
|
||||
#!/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());
|
||||
Reference in New Issue
Block a user