commit b5ad2bb9c85c95b11e8652128a7322d43ce384f9 Author: Sebastian Fischer Date: Sat Dec 14 14:10:55 2024 +0100 [TASK] Add base files diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f4f67f6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,51 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +# TS/JS-Files +[*.{ts,js,es6}] +indent_size = 2 + +# JSON-Files +[*.json] +indent_style = tab + +# ReST-Files +[*.rst] +indent_size = 3 +max_line_length = 80 + +# YAML-Files +[*.{yaml,yml}] +indent_size = 2 + +# package.json +[package.json] +indent_size = 2 + +# TypoScript +[*.{typoscript,tsconfig}] +indent_size = 2 + +# XLF-Files +[*.xlf] +indent_style = tab + +# SQL-Files +[*.sql] +indent_style = tab +indent_size = 2 + +# .htaccess +[{_.htaccess,.htaccess}] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e956afd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.cache/ +.idea/ +vendor/ +composer.lock diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..f05c28b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,26 @@ +# To contribute improvements to CI/CD templates, please follow the Development guide at: +# https://docs.gitlab.com/ee/development/cicd/templates.html +# This specific template is located at: +# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Composer.gitlab-ci.yml + +# Publishes a tag/branch to Composer Packages of the current project +publish: + image: curlimages/curl:latest + stage: build + rules: + - if: '$CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "main" || $CI_COMMIT_TAG =~ /^\d+.\d+.\d+/' + variables: + URL: "$CI_SERVER_PROTOCOL://$CI_SERVER_HOST:$CI_SERVER_PORT/api/v4/projects/$CI_PROJECT_ID/packages/composer?job_token=$CI_JOB_TOKEN" + script: + - version=$([[ -z "$CI_COMMIT_TAG" ]] && echo "branch=$CI_COMMIT_REF_NAME" || echo "tag=$CI_COMMIT_TAG") + - insecure=$([ "$CI_SERVER_PROTOCOL" = "http" ] && echo "--insecure" || echo "") + - response=$(curl -s -w "\n%{http_code}" $insecure --data $version $URL) + - code=$(echo "$response" | tail -n 1) + - body=$(echo "$response" | head -n 1) + # Output state information + - if [ $code -eq 201 ]; then + echo "Package created - Code $code - $body"; + else + echo "Could not create package - Code $code - $body"; + exit 1; + fi diff --git a/Classes/Config/AbstractConfiguration.php b/Classes/Config/AbstractConfiguration.php new file mode 100644 index 0000000..4d5e537 --- /dev/null +++ b/Classes/Config/AbstractConfiguration.php @@ -0,0 +1,91 @@ +tasks as $name => $config) { + $this->registerTask($name, $config); + } + } + + protected function registerTask(string $name, array $config): void + { + $methodName = $config['methodName'] ?? ''; + + if ($methodName === '' || !method_exists(static::class, $methodName)) { + return; + } + + $task = new Task($name, $this->$methodName(...)); + + $description = $this->getFunctionSummary($methodName); + if ($description) { + $task->desc($description); + } + + $stages = $config['stages'] ?? []; + if (count($stages)) { + $task->onStage(...$stages); + } + + $deployer = Deployer::get(); + $deployer->tasks->set($name, $task); + } + + protected function getFunctionSummary(string $function): string + { + $summary = ''; + try { + $reflector = new ReflectionMethod(Deployment::class, $function); + $comment = $reflector->getDocComment(); + if ($comment) { + $summary = DocBlockFactory::createInstance()->create($comment)->getSummary(); + } + } catch (\Exception) { + } + return $summary; + } + + protected function getTasks(): array + { + return array_keys($this->tasks); + } + + protected function get(string $name, $default = null) + { + return get($name, $default) ?? ( + isset(Deployer::get()->config[$name]) + ? Deployer::get()->config->get($name) + : $default + ); + } + + protected function set(string $name, $value): void + { + set($name, $value); + } + + protected function has(string $name): bool + { + return has($name) || Deployer::get()->config->has($name); + } + + protected function hasArray(string $name): bool + { + return $this->has($name) && is_array($this->get($name)); + } +} diff --git a/Classes/Config/Deployment.php b/Classes/Config/Deployment.php new file mode 100644 index 0000000..6452c79 --- /dev/null +++ b/Classes/Config/Deployment.php @@ -0,0 +1,660 @@ + ['methodName' => 'remotePrepare'], + 'rsync:warmup' => [], + 'deploy:prepare' => [], + 'deploy:lock' => [], + 'deploy:release' => [], + 'deploy:update_code' => [], + //'rsync:update_code' => [], + + 'local:vendors' => ['methodName' => 'localVendors'], + 'local:create_folder' => ['methodName' => 'localCreateFolder'], + 'local:write_release' => ['methodName' => 'localWriteRelease'], + 'local:shared' => ['methodName' => 'localShared'], + 'local:writable_files' => ['methodName' => 'localWritableFiles'], + 'local:executable_files' => ['methodName' => 'localExecutableFiles'], + 'deploy:clear_paths' => [], + 'local:symlink' => ['methodName' => 'localSymlink'], + 'local:cleanup' => ['methodName' => 'localCleanup'], + + 'rsync:remote' => [], + 'remote:change_group' => ['methodName' => 'remoteChangeGroup'], + 'remote:writable' => ['methodName' => 'remoteWritable'], + 'remote:db_compare' => ['methodName' => 'remoteDbCompare'], + 'remote:fix_folder_structure' => ['methodName' => 'remoteFixFolderStructure'], + 'rsync:switch_current' => [], + 'remote:clear_cache' => ['methodName' => 'remoteClearCache'], + 'remote:clear_opcache' => ['methodName' => 'remoteClearOpcache'], + 'rsync:remote_additional_targets' => [], + 'rsync:switch_current_additional_targets' => [], + 'remote:hetzner_shared_hosting' => ['methodName' => 'remoteHetznerSharedHosting'], + + 'local:echo_release_number' => ['methodName' => 'echoReleaseNumber'], + 'deploy:unlock' => [], + ]; + + public function __construct(array $tasks = []) + { + if (count($tasks)) { + $this->tasks = $tasks; + } + $this->loadBasicDeployerFiles(); + $this->setDefaultConfiguration(); + $this->loadHostConfiguration(); + $this->initializeTasks(); + $this->initializeMainTask(); + } + + protected function loadBasicDeployerFiles(): void + { + $commonFile = realpath(__DIR__ . '/../../../../Build/vendor/deployer/deployer/recipe/common.php'); + if (file_exists($commonFile)) { + require $commonFile; + } + + new Rsync(); + } + + protected function setDefaultConfiguration(): void + { + $this->set('CI_PROJECT_DIR', getEnv('CI_PROJECT_DIR')); + $this->set('CI_HOST', getEnv('CI_HOST')); + $this->set('ENVIRONMENT_NAME', getEnv('ENVIRONMENT_NAME')); + $this->set('INSTANCE_ID', getEnv('INSTANCE_ID')); + + $this->set('app_container_path', ''); + + $this->set('prepare_dirs', [ + '.dep', + 'releases', + 'shared', + ]); + + $this->set('ignore_update_code_paths', [ + 'var/cache', + 'var/log/*', + 'Build', + 'resources', + '.idea', + '.editorconfig', + '.git', + '.gitignore', + '.gitlab-ci.yml', + ]); + + $this->set('writable_dirs', [ + 'var', + ]); + + $this->set('clear_paths', [ + 'composer.json', + 'composer.lock', + ]); + } + + protected function loadHostConfiguration(): void + { + // read configuration + inventory(__DIR__ . '/../../../../Build/hosts.yaml'); + } + + protected function initializeMainTask(): void + { + // Main deployment task + task('deploy', $this->getTasks())->desc('Deploy your project'); + + // Display success message on completion + after('deploy', 'success'); + + // [Optional] if deploy fails automatically unlock. + after('deploy:failed', 'deploy:unlock'); + } + + protected function getApplicationContext(): string + { + return $this->has('application_context') ? + 'TYPO3_CONTEXT="' . $this->get('application_context') . '" ' : + ''; + } + + protected function getSudo(): string + { + return $this->get('clear_use_sudo') ? 'sudo ' : ''; + } + + protected function processRemoteTask(callable $callback): void + { + $config = Context::get()->getConfig(); + if ($config->get('local') && $config->get('remote_path')) { + // backup + $contextBackup = Context::pop(); + $config->set('local', false); + + // create temporary host + $hostname = $contextBackup->getHost()->getHostname(); + print_r($hostname); + $host = new Host($hostname); + $methods = [ + 'hostname', + 'user', + 'port', + 'configFile', + 'identityFile', + 'forwardAgent', + 'multiplexing', + 'sshOptions', + 'sshFlags', + 'shellCommand', + ]; + foreach ($methods as $method) { + if ($config->has($method)) { + $host->$method($config->get($method)); + } + } + foreach ($config->getCollection()->getIterator() as $name => $value) { + $host->set($name, $value); + } + + // create new context with host + $context = new Context($host, $contextBackup->getInput(), $contextBackup->getOutput()); + Context::push($context); + + // execute the task + \Closure::bind($callback, $this)(); + + // restore + Context::pop(); + $config->set('local', true); + Context::push($contextBackup); + } + } + + /** + * Prepare remote path exists + */ + public function remotePrepare(): void + { + if ($this->hasArray('prepare_dirs')) { + $callback = function () { + $prepareDirs = $this->get('prepare_dirs'); + foreach ($prepareDirs as $dir) { + if (test('[ ! -e "{{remote_path}}/' . $dir . '" ]')) { + run($this->getSudo() . ' mkdir -p {{remote_path}}/' . $dir); + } + } + }; + $this->processRemoteTask($callback); + } + } + + /** + * Installing vendors + */ + public function localVendors(): void + { + if (!commandExist('unzip')) { + writeln( + 'To speed up composer installation setup "unzip" command' . + ' with PHP zip extension https://goo.gl/sxzFcD' + ); + } + + // if composer.json exists + if (test('[ -d $(echo {{release_path}}) ] && [ -e "{{release_path}}/composer.json" ]')) { + run('{{bin/composer}} --working-dir={{release_path}} {{composer_options}};'); + } + } + + /** + * Creating necessary folders + */ + public function localCreateFolder(): void + { + $sudo = $this->getSudo(); + + if (test('[ ! -d {{release_path}}/var/cache ]')) { + run('mkdir -p {{release_path}}/var/cache'); + } + run($sudo . ' chmod -R 775 {{release_path}}/var/cache'); + + if (test('[ ! -d {{release_path}}/var/log ]')) { + run('mkdir -p {{release_path}}/var/log'); + } + run($sudo . ' chmod -R 775 {{release_path}}/var/log'); + } + + public function localWriteRelease(): void + { + run('echo ' . getEnv('CI_COMMIT_REF_NAME') . ' > {{release_path}}/release'); + } + + /** + * Creating symlinks for shared files and dirs in cms private folder + */ + public function localShared(): void + { + if ($this->hasArray('shared_dirs_private')) { + $this->sharedFolders($this->get('shared_dirs_private'), 'private'); + } + + if ($this->hasArray('shared_dirs_public')) { + $this->sharedFolders($this->get('shared_dirs_public'), 'public'); + } + + if ($this->hasArray('shared_files_private')) { + $this->sharedFiles($this->get('shared_files_private'), 'private'); + } + + if ($this->hasArray('shared_files_public')) { + $this->sharedFiles($this->get('shared_files_public'), 'public'); + } + } + + protected function sharedFolders(array $sharedDirs, string $folder): void + { + // Validate shared_dir, find duplicates + foreach ($sharedDirs as $a) { + foreach ($sharedDirs as $b) { + if ( + $a !== $b + && str_starts_with(rtrim($a, '/') . 'Deployment.php/', rtrim($b, '/') . '/') + ) { + throw new Exception("Can not share same dirs `$a` and `$b`."); + } + } + } + + foreach ($sharedDirs as $dir) { + // Check if shared dir does not exist. + if (test('[ ! -d {{deploy_path}}/shared/' . $dir . ' ]')) { + // Create shared dir if it does not exist. + run('mkdir -p {{deploy_path}}/shared/' . $dir); + + // If release contains shared dir, copy that dir from release to shared. + if (test('[ -d $(echo {{release_path}}/' . $folder . '/' . $dir . ') ]')) { + run('cp -rv {{release_path}}/' . $folder . '/$dir {{deploy_path}}/shared/' . dirname($dir)); + } + } + + // Remove from source. + run('rm -rf {{release_path}}/' . $folder . '/' . $dir); + + // Create path to shared dir in release dir if it does not exist. + // Symlink will not create the path and will fail otherwise. + run('mkdir -p `dirname {{release_path}}/' . $folder . '/' . $dir . '`'); + + // Symlink shared dir to release dir + run('cd {{deploy_path}} && {{bin/symlink}} ../../../shared/' + . $dir . ' {{release_path}}/' . $folder . '/' . $dir); + } + } + + protected function sharedFiles(array $sharedFiles, string $folder): void + { + foreach ($sharedFiles as $file) { + $dirname = dirname($file); + + // Create dir of shared file + run('mkdir -p {{deploy_path}}/shared/' . $dirname); + + // Check if shared file does not exist in shared and file exist in release + if ( + test('[ ! -f {{deploy_path}}/shared/' . $file + . ' ] && [ -f {{release_path}}/' . $folder . '/' . $file . ' ]') + ) { + // Copy file in shared dir + run('cp -rv {{release_path}}/' . $folder . '/' . $file . ' {{deploy_path}}/shared/' . $file); + } + + // Remove from source. + if (test('[ -f $(echo {{release_path}}/' . $folder . '/' . $file . ') ]')) { + run('rm -rf {{release_path}}/' . $folder . '/' . $file); + } + + // Ensure dir is available in release + if (test('[ ! -d $(echo {{release_path}}/' . $folder . '/' . $dirname . ') ]')) { + run('mkdir -p {{release_path}}/' . $folder . '/' . $dirname); + } + + // Touch shared + run('touch {{deploy_path}}/shared/' . $file); + + // Symlink shared dir to release dir + run('cd {{deploy_path}} && {{bin/symlink}} ../../../shared/' + . $file . ' {{release_path}}/' . $folder . '/' . $file); + } + } + + /** + * Set writable bit on files in "writable_files" + */ + public function localWritableFiles(): void + { + if ($this->hasArray('writable_files')) { + $paths = $this->get('writable_files'); + foreach ($paths as $path) { + if (test('[ -e "{{release_path}}/' . $path . '" ]')) { + run($this->getSudo() . ' chmod g+w {{release_path}}/' . $path); + } + } + } + } + + /** + * Set executable bit on files in "executable_files" + */ + public function localExecutableFiles(): void + { + if ($this->hasArray('executable_files')) { + $sudo = $this->getSudo(); + $paths = $this->get('executable_files'); + foreach ($paths as $path) { + if (test('[ -e "{{release_path}}/' . $path . '" ]')) { + run($sudo . ' chmod g+x {{release_path}}/' . $path); + run($sudo . ' chmod u+x {{release_path}}/' . $path); + } + } + } + } + + /** + * Creating symlink to release + */ + public function localSymlink(): void + { + $sudo = $this->getSudo(); + + if (test('[ -L "{{deploy_path}}/current" ] || [ -D "{{deploy_path}}/current" ]')) { + // Remove previous current + run($sudo . ' rm {{deploy_path}}/current'); + } + + // Atomic override symlink. + run($sudo . ' cd {{deploy_path}} && {{bin/symlink}} releases/{{release_name}} current'); + // Remove release link. + run($sudo . ' cd {{deploy_path}} && rm release'); + } + + /** + * Change usergroup for the hole project + */ + public function remoteChangeGroup(): void + { + if ($this->get('http_group')) { + $callback = function () { + $sudo = $this->getSudo(); + $runOpts = []; + if ($sudo) { + $runOpts['tty'] = $this->get('writable_tty', false); + } + + $path = parse('{{remote_path}}/releases/{{release_name}}/'); + run($sudo . ' chgrp -H -R ' . $this->get('http_group') . ' ' . $path, $runOpts); + run($sudo . ' chmod -R 2755 ' . $path, $runOpts); + }; + $this->processRemoteTask($callback); + } + } + + /** + * Set files and/or folders writable + */ + public function remoteWritable(): void + { + if ($this->hasArray('writable_dirs') || $this->hasArray('writable_files')) { + $callback = function () { + $sudo = $this->getSudo(); + $runOpts = []; + if ($sudo) { + $runOpts['tty'] = get('writable_tty', false); + } + $recursive = $this->get('writable_recursive') ? '-R' : ''; + $httpGroup = $this->get('http_group', false); + $path = parse('{{remote_path}}/releases/{{release_name}}/'); + + if ($this->hasArray('writable_dirs')) { + foreach ($this->get('writable_dirs') as $dir) { + if (test('[ ! -d "' . $path . $dir . '" ]')) { + run('mkdir -p ' . $path . $dir); + } + run($sudo . ' chmod -R 2775 ' . $path . $dir); + if ($httpGroup) { + run( + $sudo . ' chgrp -H ' . $recursive . ' ' . $httpGroup . ' ' . $path . $dir, + $runOpts + ); + } + } + } + + if ($this->hasArray('writable_files')) { + foreach ($this->get('writable_files') as $file) { + if (test('[ -e "' . $path . $file . '" ]')) { + run($sudo . ' chmod g+w ' . $path . $file); + if ($httpGroup) { + run($sudo . ' chgrp ' . $httpGroup . ' ' . $path . $file, $runOpts); + } + } + } + } + }; + $this->processRemoteTask($callback); + } + } + + /** + * Database migration + */ + public function remoteDbCompare(): void + { + $callback = function () { + $path = parse('{{app_container_path}}') ?: parse('{{remote_path}}'); + + $php = $this->getSudo() . parse('{{bin/php}} '); + if (!str_contains($php, 'TYPO3_CONTEXT')) { + $php = $this->getApplicationContext() . $php; + } + + $result = run( + $php . $this->getTypo3Bin($path, parse('{{remote_path}}')) . ' database:updateschema "*.add,*.change"' + ); + writeln($result); + }; + $this->processRemoteTask($callback); + } + + /** + * Fix folder structure + */ + public function remoteFixFolderStructure(): void + { + $callback = function () { + $path = parse('{{app_container_path}}') ?: parse('{{remote_path}}'); + + $php = $this->getSudo() . parse('{{bin/php}} '); + if (!str_contains($php, 'TYPO3_CONTEXT')) { + $php = $this->getApplicationContext() . $php; + } + + $result = run( + $php . $this->getTypo3Bin($path, parse('{{remote_path}}')) . ' install:fixfolderstructure' + ); + writeln($result); + }; + $this->processRemoteTask($callback); + } + + /** + * Clear cache via typo3 cache:flush + */ + public function remoteClearCache(): void + { + $callback = function () { + $path = parse('{{app_container_path}}') ?: parse('{{remote_path}}'); + + $php = $this->getSudo() . parse('{{bin/php}} '); + if (!str_contains($php, 'TYPO3_CONTEXT')) { + $php = $this->getApplicationContext() . $php; + } + + $result = run($php . $this->getTypo3Bin($path, parse('{{remote_path}}')) . ' cache:flush'); + writeln($result); + }; + $this->processRemoteTask($callback); + } + + /** + * Clear opcache via curl on {CI_HOST}/cache.php + */ + public function remoteClearOpcache(): void + { + $webDomain = rtrim($this->get('web_domain'), '/'); + $htaccess = $this->has('htaccess') ? '--user ' . $this->get('htaccess') : ''; + run('curl ' . $htaccess . ' -sk ' . $webDomain . '/cache.php'); + } + + public function remoteHetznerSharedHosting(): void + { + if ( + ($this->has('hetzner_shared_hosting') && $this->get('hetzner_shared_hosting')) + && ($this->has('hetzner_cache_command') && $this->get('hetzner_cache_command')) + ) { + $callback = function () { + $result = run($this->get('hetzner_cache_command')); + writeln($result); + }; + $this->processRemoteTask($callback); + } + } + + /** + * Cleaning up old releases + */ + public function localCleanup(): void + { + [$list, $releases] = $this->releasesList(); + $sudo = $this->get('cleanup_use_sudo') ? 'sudo' : ''; + $runOpts = []; + + if ($sudo) { + $runOpts['tty'] = $this->get('cleanup_tty', false); + } + + foreach ($list as $release) { + run($sudo . ' rm -rf {{deploy_path}}/releases/' . $release, $runOpts); + } + + run('cd {{deploy_path}} && if [ -e release ]; then ' . $sudo . ' rm release; fi', $runOpts); + run('cd {{deploy_path}} && if [ -h release ]; then ' . $sudo . ' rm release; fi', $runOpts); + + $this->updateReleaseFile($releases); + } + + public function echoReleaseNumber(): void + { + writeln('This release folder {{release_path}}'); + } + + /** + * Override deployer releases_list + */ + protected function releasesList(): array + { + cd('{{deploy_path}}'); + $keep = (int)$this->get('keep_releases'); + + // If there is no releases return empty list. + if (!test('[ -d releases ] && [ "$(ls -A releases)" ]')) { + return []; + } + + // Will list only dirs in releases. + $list = explode("\n", run('cd releases && ls -t -1 --directory */')); + + // Prepare list. + $list = array_map(function ($release) { + return basename(rtrim(trim($release), '/')); + }, $list); + + // Releases list. + $releases = []; + // Other will be ignored. + $metaInfo = $this->getReleasesInformation(); + $i = 0; + foreach ($metaInfo as $release) { + if ($i++ < $keep && is_array($release) && count($release) > 1) { + $index = array_search($release[1], $list, true); + if ($index !== false) { + $releases[] = $release; + unset($list[$index]); + } + } + } + + return [$list, array_reverse($releases)]; + } + + /** + * Collect releases based on .dep/releases info. + * + * @return array + */ + protected function getReleasesInformation(): array + { + if (test('[ -f .dep/releases ]')) { + $csv = run('cat .dep/releases'); + $releaseInformation = Csv::parse($csv); + + usort($releaseInformation, function ($a, $b) { + return strcmp($b[0], $a[0]); + }); + } + return $releaseInformation ?? []; + } + + protected function getTypo3Bin(string $path, string $testPath): string + { + if (test('[ -f ' . $testPath . '/releases/{{release_name}}/vendor/bin/typo3cms ]')) { + $bin = $path . '/releases/{{release_name}}/vendor/bin/typo3cms'; + } else { + $bin = $path . '/releases/{{release_name}}/vendor/bin/typo3'; + } + return $bin; + } + + protected function updateReleaseFile(array $releases): void + { + cd('{{deploy_path}}'); + $first = true; + foreach ($releases as $release) { + if ($first) { + $first = false; + run('echo "' . implode(',', $release) . '" > .dep/releases'); + } else { + run('echo "' . implode(',', $release) . '" >> .dep/releases'); + } + } + } +} diff --git a/Classes/Config/Rsync.php b/Classes/Config/Rsync.php new file mode 100644 index 0000000..a345e92 --- /dev/null +++ b/Classes/Config/Rsync.php @@ -0,0 +1,457 @@ + [ + '.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; + } +} diff --git a/Classes/Services/Environment.php b/Classes/Services/Environment.php new file mode 100644 index 0000000..375ed33 --- /dev/null +++ b/Classes/Services/Environment.php @@ -0,0 +1,18 @@ +getComposer()->getPackage()->getExtra(); + foreach (($extras['deployer'] ?? []) as $key => $value) { + putenv(strtoupper($key) . '=' . $value); + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..49a5ff8 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +## To install add Build/composer.json to the project +```json +{ + "name": "cp/build", + "description": "Project deployment with support for deployphp", + "repositories": [ + { + "type": "git", + "url": "https://gitlab.cp-compartner.de/cpcompartner/build-config.git" + } + ], + "require": { + "cp/build-config": "^1.7.3" + }, + "scripts": { + "staging": "dep -vv --file=vendor/cp/build-config/deploy.php deploy staging --branch", + "production": "dep --file=vendor/cp/build-config/deploy.php deploy production --tag", + + "phpcs-fixer": "php-cs-fixer fix --config=vendor/cp/build-config/php_cs.php", + "phpstan": "phpstan analyse --configuration=vendor/cp/build-config/phpstan.neon", + "rector": "rector process --config=vendor/cp/build-config/rector.php", + "php72-lint": "find ../packages ../private/typo3conf -name '*.php' -exec php7.2 -l {} 1> /dev/null \\;", + "php74-lint": "find ../packages ../private/typo3conf -name '*.php' -exec php7.4 -l {} 1> /dev/null \\;" + } +} +``` + +## Usage per composer + +Install it with: +```bash +./composer update +``` + +Deploy branch develop to staging +```bash +./composer staging develop +``` + +Deploy tag 1.0.0 to production +```bash +./composer production 1.0.0 +``` + +PHPCS fixer usage: +```bash +./composer phpcs-fixer ../package/cp_sitepackage +``` + +PHPStan usage: +```bash +./composer phpstan ../package/cp_sitepackage +``` + +TYPO3 Rector usage: +```bash +./composer rector ../package/cp_sitepackage +``` + +## Overriding default values per host in hosts.yaml +```yaml +.base: &base + local: true + alias: '{{CI_HOST}}' + deploy_path: '{{CI_PROJECT_DIR}}/cache/{{ENVIRONMENT_NAME}}' + composer_options: '{{composer_action}} --verbose --prefer-dist --no-progress --no-interaction --no-dev --optimize-autoloader --no-suggest --ignore-platform-reqs' + + prepare_dirs: + - .dep + - releases + - shared/fileadmin + + shared_dirs_private: + - fileadmin + + writable_dirs: + - private/typo3temp + - var + writable_files: + - private/typo3conf/PackageStates.php + + rsync: + exclude: + - '-,p private/typo3temp/var/Cache' +``` diff --git a/alias.sh b/alias.sh new file mode 100644 index 0000000..af13124 --- /dev/null +++ b/alias.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +function composer() { + mkdir -p "${HOME}/.config/composer" + mkdir -p "${HOME}/.cache/composer" + docker run -t \ + --user $(id -u):33 \ + --env COMPOSER_CACHE_DIR=/cache \ + --env SSH_AUTH_SOCK=/ssh-agent \ + --env CI_HOST \ + --env CI_PROJECT_DIR \ + --env ENVIRONMENT_NAME \ + --env INSTANCE_ID \ + --env ADDITIONAL_CONFIG_FILE \ + --env TYPO3_CONTEXT \ + --env STAGE \ + --network db \ + --volume "$(readlink -f ${SSH_AUTH_SOCK})":/ssh-agent \ + --volume /etc/passwd:/etc/passwd:ro \ + --volume "${HOME}":"${HOME}" \ + --volume "${HOME}/.config/composer":/tmp \ + --volume "${HOME}/.cache/composer":/cache \ + --volume "${CI_PROJECT_DIR}":"${CI_PROJECT_DIR}" \ + --volume "${PWD%}":/app \ + evoweb/php:composer $@ +} +alias composer=composer diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..daaa08e --- /dev/null +++ b/composer.json @@ -0,0 +1,16 @@ +{ + "name": "evoweb/deployer-config", + "description": "TYPO3 CMS Distribution with utilization of deployphp", + "type": "project", + + "require": { + "deployer/deployer": "^v6.9 || dev-master", + "phpdocumentor/reflection-docblock": "^5.3" + }, + + "autoload": { + "psr-4": { + "Evoweb\\DeployerConfig\\": "Classes/" + } + } +} diff --git a/deploy.php b/deploy.php new file mode 100644 index 0000000..e190861 --- /dev/null +++ b/deploy.php @@ -0,0 +1,3 @@ +