[ '.git', 'deploy.php', 'deploy.lock', 'current', 'shared/', 'var/*', ], 'exclude-file' => false, 'include' => [ 'var/', ], 'include-file' => false, 'filter' => [], 'filter-file' => false, 'filter-perdir' => false, 'flags' => 'rzlc', 'options' => ['delete', 'delete-after'], 'timeout' => 600, ]; protected array $tasks = [ 'rsync:warmup' => ['methodName' => 'warmup'], 'rsync:update_code' => ['methodName' => 'updateCode'], 'rsync:remote' => ['methodName' => 'remote'], 'rsync:remote_additional_targets' => ['methodName' => 'remoteAdditionalTargets'], 'rsync:switch_current' => ['methodName' => 'switchCurrent'], 'rsync:switch_current_additional_targets' => ['methodName' => 'switchCurrentAdditionalTargets'], ]; public function __construct() { $this->setDefaultConfiguration(); $this->initializeTasks(); } protected function setDefaultConfiguration(): void { $this->set('port', 22); $this->set('rsync', []); $this->set('rsync_default', $this->defaultConfig); $this->set('rsync_src', '{{deploy_path}}'); $this->set('rsync_dest', '{{user}}@{{hostname}}:\'{{remote_path}}/\''); $this->set('rsync_config', $this->rsyncConfig(...)); $this->set('rsync_flags', $this->rsyncFlags(...)); $this->set('rsync_excludes', $this->rsyncExcludes(...)); $this->set('rsync_excludes_download', $this->rsyncExcludesDownload(...)); $this->set('rsync_excludes_upload', $this->rsyncExcludesUpload(...)); $this->set('rsync_includes', $this->rsyncIncludes(...)); $this->set('rsync_filter', $this->rsyncFilter(...)); $this->set('rsync_options', $this->rsyncOptions(...)); $this->set('rsync_timeout', $this->rsyncTimeout(...)); } /** * Warmup remote Rsync target */ public function warmup(): void { if (!test('[ -d "{{deploy_path}}" ]')) { runLocally('mkdir -p {{deploy_path}}'); } if (test('[ -d "{{deploy_path}}" ]')) { $identityFile = $this->get('identityFile') ? ' -i ' . $this->get('identityFile') : ''; run( 'rsync \ -e \'ssh -p {{port}}' . $identityFile . '\' \ {{rsync_flags}} \ {{rsync_options}} \ {{rsync_timeout}} \ --exclude=' . escapeshellarg('shared') . ' \ {{rsync_excludes_download}} \ {{rsync_includes}} \ {{rsync_filter}} \ {{rsync_dest}} {{deploy_path}}' ); } else { writeln('No destination folder found.'); } } /** * Copy repository content to release folder */ public function updateCode(): void { $pathConfigName = 'ignore_update_code_paths'; if ($this->hasArray($pathConfigName)) { $excludes = ''; $ignoreUpdateCodePaths = $this->get($pathConfigName); foreach ($ignoreUpdateCodePaths as $path) { $excludes .= ' --exclude=\'' . $path . '\''; } $identityFile = $this->get('identityFile') ? ' -i ' . $this->get('identityFile') : ''; run( 'rsync \ -e \'ssh -p {{port}}' . $identityFile . '\' \ {{rsync_flags}} \ {{rsync_options}} \ {{rsync_timeout}} \ --exclude=' . escapeshellarg('shared') . ' \ ' . $excludes . ' \ {{rsync_includes}} \ {{rsync_filter}} \ {{CI_PROJECT_DIR}}/ \ {{release_path}}' ); } } /** * Rsync local->remote */ public function remote(): void { $config = $this->get('rsync_config'); $server = Context::get()->getHost(); $identityFile = $this->get('identityFile') ? ' -i ' . $this->get('identityFile') : ''; if ($server instanceof Localhost) { runLocally( 'rsync \ -e \'ssh -p {{port}}' . $identityFile . '\' \ {{rsync_flags}} \ {{rsync_options}} \ {{rsync_timeout}} \ {{rsync_includes}} \ --exclude=' . escapeshellarg('shared/') . ' \ {{rsync_excludes_upload}} \ {{rsync_filter}} \ \'{{rsync_src}}/\' {{rsync_dest}}', $config ); return; } runLocally( 'rsync \ -e \'ssh -p {{port}}' . $identityFile . '\' \ {{rsync_flags}} \ {{rsync_options}} \ {{rsync_timeout}} \ {{rsync_includes}} \ --exclude=' . escapeshellarg('shared/') . ' \ {{rsync_excludes_upload}} \ {{rsync_filter}} \ \'{{rsync_src}}/\' {{user}}@{{hostname}}:\'{{remote_path}}/\'', $config ); } /** * Rsync local->additional targets */ public function remoteAdditionalTargets(): void { $configName = 'additional_remote_targets'; if ($this->hasArray($configName)) { $config = $this->get('rsync_config'); $identityFile = $this->get('identityFile') ? ' -i ' . $this->get('identityFile') : ''; $targets = $this->get($configName); foreach ($targets as $target) { $port = $target['port'] ?? '{{port}}'; $user = $target['user'] ?? '{{user}}'; $remotePath = $target['remote_path'] ?: '{{remote_path}}'; runLocally( 'rsync \ -e \'ssh -p ' . $port . $identityFile . '\' \ {{rsync_flags}} \ {{rsync_options}} \ {{rsync_timeout}} \ {{rsync_includes}} \ --exclude=' . escapeshellarg('shared/') . ' \ {{rsync_excludes_upload}} \ {{rsync_filter}} \ \'{{rsync_src}}/\' ' . $user . '@' . $target['hostname'] . ':\'' . $remotePath . '/\'', $config ); } } else { writeln('No additional remote target configured'); } } /** * Sync current after release was uploaded */ public function switchCurrent(): void { $config = $this->get('rsync_config'); $server = Context::get()->getHost(); $identityFile = $this->get('identityFile') ? ' -i ' . $this->get('identityFile') : ''; if ($server instanceof Localhost) { runLocally( 'rsync \ -e \'ssh -p {{port}}' . $identityFile . '\' \ {{rsync_flags}} \ {{rsync_options}} \ \'{{rsync_src}}/current\' {{rsync_dest}}', $config ); return; } runLocally( 'rsync \ -e \'ssh -p {{port}}' . $identityFile . '\' \ {{rsync_flags}} \ {{rsync_options}} \ \'{{rsync_src}}/current\' {{user}}@{{hostname}}:\'{{remote_path}}/\'', $config ); } /** * Sync current after release was uploaded */ public function switchCurrentAdditionalTargets(): void { $configName = 'additional_remote_targets'; if ($this->hasArray($configName)) { $config = $this->get('rsync_config'); $identityFile = $this->get('identityFile') ? ' -i ' . $this->get('identityFile') : ''; $targets = $this->get($configName); foreach ($targets as $target) { $port = $target['port'] ?? '{{port}}'; $user = $target['user'] ?? '{{user}}'; $remotePath = $target['remote_path'] ?: '{{remote_path}}'; runLocally( 'rsync \ -e \'ssh -p ' . $port . $identityFile . '\' \ {{rsync_flags}} \ {{rsync_options}} \ \'{{rsync_src}}/current\'' . ' ' . $user . '@' . $target['hostname'] . ':\'' . $remotePath . '/\'', $config ); } } else { writeln('No additional remote target configured'); } } protected function getSource(): string { $source = $this->get('rsync_src'); while (is_callable($source)) { $source = $source(); } if (!trim($source)) { // if $src is not set here rsync is going to do a directory listing // exiting with code 0, since only doing a directory listing clearly // is not what we want to achieve we need to throw an exception throw new RuntimeException('You need to specify a source path.'); } return $source; } protected function getDestination(): string { $destination = $this->get('rsync_dest'); while (is_callable($destination)) { $destination = $destination(); } if (!trim($destination)) { // if $dst is not set here we are going to sync to root // and even worse - depending on rsync flags and permission - // might end up deleting everything we have write permission to throw new RuntimeException('You need to specify a destination path.'); } return $destination; } public function rsyncConfig(): array { $default = $this->get('rsync_default'); $config = $this->get('rsync'); return Rsync::arrayMergeRecursiveDistinct($default, $config); } public function rsyncFlags(): string { $config = $this->get('rsync_config'); $flags = $config['flags'] ?? ''; return $flags ? ' -' . $flags : ''; } public function rsyncExcludes(): string { $config = $this->get('rsync_config'); $excludes = $config['exclude'] ?? []; $excludeFile = $config['exclude-file'] ?? ''; $excludesRsync = ''; foreach ($excludes as $exclude) { $excludesRsync .= ' --exclude=' . escapeshellarg($exclude); } if ( !empty($excludeFile) && file_exists($excludeFile) && is_file($excludeFile) && is_readable($excludeFile) ) { $excludesRsync .= ' --exclude-from=' . escapeshellarg($excludeFile); } return $excludesRsync; } public function rsyncExcludesDownload(): string { $config = $this->get('rsync_config'); $excludes = $config['exclude_download'] ?? $config['exclude'] ?? []; $excludeFile = $config['exclude-file'] ?? ''; $excludesRsync = ''; foreach ($excludes as $exclude) { $excludesRsync .= ' --exclude=' . escapeshellarg($exclude); } if ( !empty($excludeFile) && file_exists($excludeFile) && is_file($excludeFile) && is_readable($excludeFile) ) { $excludesRsync .= ' --exclude-from=' . escapeshellarg($excludeFile); } return $excludesRsync; } public function rsyncExcludesUpload(): string { $config = $this->get('rsync_config'); $excludes = $config['exclude_upload'] ?? $config['exclude'] ?? []; $excludeFile = $config['exclude-file'] ?? ''; $excludesRsync = ''; foreach ($excludes as $exclude) { $excludesRsync .= ' --exclude=' . escapeshellarg($exclude); } if ( !empty($excludeFile) && file_exists($excludeFile) && is_file($excludeFile) && is_readable($excludeFile) ) { $excludesRsync .= ' --exclude-from=' . escapeshellarg($excludeFile); } return $excludesRsync; } public function rsyncIncludes(): string { $config = $this->get('rsync_config'); $includes = $config['include'] ?? []; $includeFile = $config['include-file'] ?? ''; $includesRsync = ''; foreach ($includes as $include) { $includesRsync .= ' --include=' . escapeshellarg($include); } if ( !empty($includeFile) && file_exists($includeFile) && is_file($includeFile) && is_readable($includeFile) ) { $includesRsync .= ' --include-from=' . escapeshellarg($includeFile); } return $includesRsync; } public function rsyncFilter(): string { $config = $this->get('rsync_config'); $filters = $config['filter'] ?? []; $filterFile = $config['filter-file'] ?? ''; $filterPerDir = $config['filter-perdir'] ?? ''; $filtersRsync = ''; foreach ($filters as $filter) { $filtersRsync .= " --filter='$filter'"; } if (!empty($filterFile)) { $filtersRsync .= " --filter='merge $filterFile'"; } if (!empty($filterPerDir)) { $filtersRsync .= " --filter='dir-merge $filterPerDir'"; } return $filtersRsync; } public function rsyncOptions(): string { $config = $this->get('rsync_config'); $options = $config['options'] ?? []; $optionsRsync = []; foreach ($options as $option) { $optionsRsync[] = '--' . $option; } return ' ' . implode(' ', $optionsRsync); } public function rsyncTimeout(): string { $config = $this->get('rsync_config'); $timeout = $config['timeout'] ?? 0; return $timeout ? ' --timeout=' . $timeout : ''; } public static function arrayMergeRecursiveDistinct(array $array1, array &$array2): array { $merged = $array1; foreach ($array2 as $key => &$value) { if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) { $merged[$key] = self::arrayMergeRecursiveDistinct($merged[$key], $value); } else { $merged[$key] = $value; } } return $merged; } }