diff --git a/.gitignore b/.gitignore index b4463379..ca37a3f4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,6 @@ /bin/phpunit /build/phabalicious.phar /docs/site +/tests/tmp node_modules docs/.vuepress/dist diff --git a/Changelog.md b/Changelog.md index 3906808d..5ed5e5e8 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,20 @@ # Changelog +## 3.2.0 / 2019-10-14 + +### New: + + * `rootFolder` is set by default now to the folder where the fabfile is located. + * All context variables are exposed as replacement patterns for using in scripts. + * new method `artifacts--git` to build an artifact and push it to a git repository, see new documentation about artifacts. + * Update documentation regarding the new artifact workflow + +### Changed + + * Refactored and renamed method `ftp-sync` to `artifacts--ftp` in preparation of artifacts--git. Be aware that you might need to change existing configuration! + +### New + ## 3.1.0 / 2019-09-27 ### New diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index c7993146..1efe1d24 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -19,6 +19,7 @@ module.exports = { "/scripts.html", "/app-scaffold.html", "/app-create-destroy.html", + "/deploying-artifacts.html", "/passwords.html", "/contribute.html", "/Changelog.html" diff --git a/docs/app-create-destroy.md b/docs/app-create-destroy.md index 0044eeda..0de29e3a 100644 --- a/docs/app-create-destroy.md +++ b/docs/app-create-destroy.md @@ -5,7 +5,7 @@ Phabalicious can create or delete a complete app with two commands: * `phab --config= app:create --copy-from=` * `phab --config= app:destroy` -Both commands executes a list of stages, which can be influenced via configuration +Both commands executes a list of stages, which can be influenced via configuration. Every method can react to the different stages and run some tasks if needed. ## the standard stages @@ -19,18 +19,34 @@ appStages: - stage: spinUp - stage: installDependencies - stage: install - createCode: - - stage: installCode - - stage: installDependencies deploy: - stage: spinUp destroy: - stage: spinDown - stage: deleteContainers + # ftpSync and gitSync are only used for deploying artifacts. + ftpSync: + - stage: installCode + - stage: installDependencies + - stage: runDeployScript + gitSync: + useLocalRepository: + - stage: installDependencies + - stage: getSourceCommitInfo + - stage: pullTargetRepository + - stage: copyFilesToTargetRepository + - stage: runDeployScript + - stage: pushToTargetRepository + pullTargetRepository: + - stage: installCode + - stage: installDependencies + - stage: getSourceCommitInfo + - stage: pullTargetRepository + - stage: copyFilesToTargetRepository + - stage: runDeployScript + - stage: pushToTargetRepository ``` -`createCode` is only used by the `ftp-sync`-method, to create a complete code version of an app. - ## Creating a new app Run the phab-command as usual. If you want to copy from an existing installation, add the `--copy-from`-option. @@ -62,3 +78,11 @@ phab --config=some-config --blueprint=feature/ create:app ``` Will create a new app from a blueprinted config. + +## Customizing via scripts + +* You can add custom scripts to your host-configuration which will be run before a stage is entered or after a stage is finished. They follow a naming-convention: + * `appCreateBefore` + * `appCreateFinished` + + e.g. `appCreateInstallDependenciesBefore` or `appCreateSpinDownFinished`. All context variables are exposed to scripts as `context.data.*` or `context.results.*` diff --git a/docs/available-tasks.md b/docs/available-tasks.md index 241f1068..f3c5336c 100644 --- a/docs/available-tasks.md +++ b/docs/available-tasks.md @@ -200,7 +200,8 @@ After a successfull deployment the `reset`-task will be run. * `git` will deploy to the latest commit for the given branch defined in the host-configuration. Submodules will be synced, and updated. * `platform` will push the current branch to the `platform` remote, which will start the deployment-process on platform.sh -* `ftp-sync` will create a copy of the app in a temporary folder and syncs this folder with the help of `lftp` with a remote-ftp-server. +* `artifacts--ftpc` will create a copy of the app in a temporary folder and syncs this folder with the help of `lftp` with a remote ftp-server. +* `artifacts--git` will create a copy of the app in a temporary folder and push it to another git-repository **Examples:** diff --git a/docs/configuration.md b/docs/configuration.md index 88f85969..9a54391d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,7 +41,8 @@ List here all needed methods for that type of project. Available methods are: * `composer` for composer support * `drupalconsole` for drupal-concole support * `platform` for deploying to platform.sh - * `ftp-sync` to deploy to a ftp-server + * `artifacts--ftp to deploy to a ftp-server + * `artifacts--git to deploy an artifact to a git repository **Example for drupal 7** @@ -88,7 +89,6 @@ hosts: backupFolder: /var/www/backups supportsInstalls: true|false supportsCopyFrom: true|false - type: dev branch: develop docker: ... @@ -124,6 +124,7 @@ This will print all host configuration for the host `staging`. * `supportsCopyFrom`, default is true. If set to true, you can use that configuration as a source for the `copy-from`-task. * `backupBeforeDeploy` is set to true for `types` `stage` and `prod`, if set to true, a backup of the DB is made before a deployment. * `tmpFolder`, default is `/tmp`. +* `environment` contains a list of environment variables to set before running any command * `shellProvider` defines how to run a shell, where commands are executed, current values are * `local`: all commands are run locally * `ssh`: all commands are run via a ssh-shell @@ -184,15 +185,25 @@ This will print all host configuration for the host `staging`. * `drushVersion` set the used crush-version, default is `8`. Drush is not 100% backwards-compatible, for phabalicious needs to know its version. * `supportsZippedBackups` default is true, set to false, when zipped backups are not supported -#### Configuration of the ftp-sync-method +#### Configuration of the artifacts--ftp-method -* `ftp` keeps all configuration bundled: +* `target` keeps all configuration bundled: * `user` the ftp-user * `password` the ftp password * `host` the ftp host * `port`, default is 21, the port to connect to * `rootFolder` the folder to copy the app into on the ftp-host. * `lftpOptions`, an array of options to pass when executing `lftp` + * `actions` a list of actions to perform. See detailed documentation for more info. + + +#### Configuration of the artifacts--git method + +* `target` contains the following options + * `repository` the url of the target repository + * `branch` the branch to use for commits + * `useLocalRepository` if set to true, phab will use the current directory as a source for the artifact, if set to false, phab will create a new app in a temporary folder and use that as a artifact + * `actions` a list of actions to perform. See detailed documentation for more info. #### Configuration of the docker-method @@ -201,7 +212,7 @@ This will print all host configuration for the host `staging`. * `name` contains the name of the docker-container. This is needed to get the IP-address of the particular docker-container when using ssh-tunnels (see above). * for docker-compose-base setups you can provide the `service` instead the name, phabalicious will get the docker name automatically from the service. -### Configuration of the mattermost-method +#### Configuration of the mattermost-method * `notifyOn`: a list of all tasks where to send a message to a Mattermost channel. Have a look at the global Mattermost-configuration-example below. diff --git a/docs/deploying-artifacts.md b/docs/deploying-artifacts.md new file mode 100644 index 00000000..ef1b5dbd --- /dev/null +++ b/docs/deploying-artifacts.md @@ -0,0 +1,182 @@ +# Deploying artifacts + +If you want to deploy artifacts, phabalicious can help you here, currently there are two methods supported: + +* artifacts--git +* artifacts--ftp + +Both methods create a new application from scratch and deploy all or parts of it to a ftp server or store the artifacts in a git repository. Both methods are using the same stage mechanisms as in `app:create`. + +Both methods needs the global `repository`-config, so they can pull the app into a new temporary folder. + +## artifacts--ftp + +ftp will create a new application in a temporary folder, install all needed dependencies, copies the data into a new temporary folder and run the deploy script of the host-config. After that is finished, phab will mirror all changed files via `lftp` to the server. `lftp` supports multiple protocols, not only ftp, but it needs to be installed on the machine running phab. + +ftp-sync use the following stages: + +* `installCode` will most of the time clone the source repository +* `installDependencies` will install all needed dependencies +* `runActions` will run all defined actions (see below) +* `runDeployScript` run the deploy-script of the host to apply some custom changes +* `syncToFtp` sync all changed files with the remote ftp server. + +### Example config + +```yaml + +hosts: + ftp-artifacts: + needs: + - git + - composer + - artifacts--ftp + - script + artifact: + user: stephan + host: sftp://localhost + port: 22 + password: my-secret + rootFolder: /home/stephan/somewhere + actions: + - action: copy + arguments: + from: "*" + to: . + - action: script + arguments: + - cp .env.example .env + - action: delete + arguments: + - .git/ + - .fabfile + - .editorconfig + - .env.example + - .gitattributes + - .gitignore + - composer.lock + - composer.json + - docker-compose.yml + - action: script + arguments: + - ls -la + - action: confirm + arguments: + question: Do you want to continue? + +``` + +The default actions for the ftp-artifact-method will copy all files to the target repo and remove the `.git`-folder and the fabfile. + +## artifacts--git + +This method will create the artifact, pull the target repository, copy all necessary files over to the target repository, commit any changes to the target repository and push the changes again. A CI listening to commits can do the actual deployment + +It is using the following stages: + +* `installCode`, creates a temporary folder and pulls the source repository. (only when `useLocalRepository` is set to false) +* `installDependencies` to install the dependencies +* `getSourceCommitInfo` get the commit hash from the source repo. +* `runActions` will run all defined actions (see below) +* `copyFilesToTargetDirectory`, copy specified `files` to the target directory, removes all files listed in `excludeFiles.gitSync` +* `runDeployScript` run the deploy script of the host-config. +* `pushToTargetRepository` commit and push all changes, using the changelog as a commit-message + +If you run phabalicious as part of a CI-setup, it might make sense to set `useLocalRepository` to true, as this will instruct phab to use the current folder as a base for the artifact and won't create a new application in another temporary folder. + +### Example config + +```yaml +hosts: + git-artifact: + needs: + - git + - composer + - artifacts--git + - script + artifact: + branch: master + repository: ssh://somewhere/repository.git + useLocalRepository: false + actions: + - action: copy + arguments: + from: '*' + to: . + - action: script + arguments: + - cp .env.example .env + - action: delete + arguments: + - .env.example + - composer.json + - composer.lock + - docker-compose.yml + - docker-compose-mbb.yml + - .projectCreated +``` + +the default actions for the git-artifact-method will copy all files to the target repo and remove the fabfile. + +## Available actions + +You can customize the list of actions be run when deploying an artifact. Here's a list of available actions + +### copy + +```yaml +- action: copy + argumnents: + from: + - file1 + - folder2 + - subfolder/file3 + to: targetsubfolder +``` + +This will copy the three mentioned files and folders into the subfolder `targetsubfolder` of the target folder. Please be aware, that you might need to create subdirectories beforehand manually via the `script`-method + +### delete + +```yaml +- action: delete + arguments: + - file1 + - folder2 + - subfolder/file3 +``` + +This action will delete the list of files and folders in the target folder. Here you can clean up the target and get rid of unneeded files. + +### exclude + +```yaml +- action: exclude + arguments: + - file1 + - folder2 + - subfolder/file3 +``` + +Similar to `delete` this will exclude the list of file and folders from be transferred to the target. For `ftp` the list of files get excluded from transferring, for `git` they will get resetted from the target repository. + +### confirm + +```yaml +- action: confirm + arguments: + question: Do you want to continue? +``` + +This action comes handy when degugging the build process, as it will stop the execution and asks the user the questions and wait for `yes` before continuing. Answering sth different will cancel the further execution. + +### script + +```yaml +- action: script + arguments: + - echo "Hello world" + - cp .env.production .env +``` + +The `script`-action will run the script from the arguments section line by line. You can use the usual replacement patterns as for other scripts. diff --git a/src/Artifact/Actions/ActionBase.php b/src/Artifact/Actions/ActionBase.php new file mode 100644 index 00000000..fd8c9234 --- /dev/null +++ b/src/Artifact/Actions/ActionBase.php @@ -0,0 +1,46 @@ +arguments = $arguments; + } + + protected function getArguments() : array + { + return $this->arguments; + } + + protected function getArgument($name) { + return $this->arguments[$name] ?? null; + } + + public function validateConfig($host_config, array $action_config, ValidationErrorBagInterface $errors) { + $service = new ValidationService($action_config, $errors, sprintf( + 'host-config.%s.%s.actions', $host_config['configName'], ArtifactsBaseMethod::PREFS_KEY)); + $service->hasKeys([ + 'action' => 'Every action needs the type of action to perform', + 'arguments' => 'Missing arguments for an action' + ]); + if (isset($action_config['arguments'])) { + $service->isArray('arguments', 'arguments need to be an array!'); + } + + if (!empty($action_config['action']) && is_array($action_config['arguments'])) { + $service = new ValidationService($action_config['arguments'], $errors, sprintf('%s.arguments', $action_config['action'])); + $this->validateArgumentsConfig($action_config, $service); + } + } + + abstract protected function validateArgumentsConfig(array $action_arguments, ValidationService $validation); + +} diff --git a/src/Artifact/Actions/ActionFactory.php b/src/Artifact/Actions/ActionFactory.php new file mode 100644 index 00000000..6cb6d0c0 --- /dev/null +++ b/src/Artifact/Actions/ActionFactory.php @@ -0,0 +1,30 @@ +hasKey('question', 'The confirm action needs a question to ask.'); + } + + public function run(HostConfig $host_config, TaskContextInterface $context) + { + if (!$context->io()->confirm($this->getArgument('question'), false)) { + throw new \RuntimeException('Cancelled by user!'); + } + } +} \ No newline at end of file diff --git a/src/Artifact/Actions/Base/CopyAction.php b/src/Artifact/Actions/Base/CopyAction.php new file mode 100644 index 00000000..ad0089f4 --- /dev/null +++ b/src/Artifact/Actions/Base/CopyAction.php @@ -0,0 +1,69 @@ +hasKey('to', 'Copy action needs a to argument'); + $validation->hasKey('from', 'Copy action needs a from argument'); + } + + /** + * @param ShellProviderInterface $shell + * @param $install_dir + * @return array + */ + private function getDirectoryContents(ShellProviderInterface $shell, $install_dir) + { + $contents = $shell->run('ls -1a ' . $install_dir, true); + return array_filter($contents->getOutput(), function ($elem) { + return !in_array($elem, ['.', '..']); + }); + } + + public function run(HostConfig $host_config, TaskContextInterface $context) + { + /** @var ShellProviderInterface $shell */ + $shell = $context->get('outerShell', $host_config->shell()); + $install_dir = $context->get('installDir', false); + $target_dir = $context->get('targetDir', false); + + $shell->pushWorkingDir($install_dir); + + $files_to_copy = $this->getArgument('from'); + if (!is_array($files_to_copy)) { + if ($files_to_copy == '*') { + $files_to_copy = $this->getDirectoryContents($shell, $install_dir); + } else { + $files_to_copy = [$files_to_copy]; + } + } + + $files_to_skip = $context->getConfigurationService()->getSetting('excludeFiles.gitSync', []); + + // Make sure that git-related files are skipped. + $files_to_skip[] = ".git"; + $to = $target_dir . '/' . $this->getArgument('to'); + + // Make sure the target directory exists before copying. + $shell->run(sprintf('mkdir -p %s', $to)); + + foreach ($files_to_copy as $file) { + if (!in_array($file, $files_to_skip)) { + $shell->run(sprintf('cp -a %s %s', $file, $to)); + } + } + + $shell->popWorkingDir(); + } +} diff --git a/src/Artifact/Actions/Base/DeleteAction.php b/src/Artifact/Actions/Base/DeleteAction.php new file mode 100644 index 00000000..01d00a01 --- /dev/null +++ b/src/Artifact/Actions/Base/DeleteAction.php @@ -0,0 +1,36 @@ +get('outerShell', $host_config->shell()); + $target_dir = $context->get('targetDir', false); + + $shell->pushWorkingDir($target_dir); + + $files_to_delete = $this->getArguments(); + foreach ($files_to_delete as $file) { + $full_path = $target_dir . '/' . $file; + $shell->run(sprintf('rm -rf %s', $full_path)); + } + + $shell->popWorkingDir(); + } +} diff --git a/src/Artifact/Actions/Base/ScriptAction.php b/src/Artifact/Actions/Base/ScriptAction.php new file mode 100644 index 00000000..618fe7b3 --- /dev/null +++ b/src/Artifact/Actions/Base/ScriptAction.php @@ -0,0 +1,43 @@ +get('outerShell', $host_config->shell()); + $target_dir = $context->get('targetDir', false); + + $shell->pushWorkingDir($target_dir); + + /** @var ScriptMethod $script */ + $script = $context->getConfigurationService()->getMethodFactory()->getMethod('script'); + + $cloned_context = clone $context; + $cloned_context->set('rootFolder', $target_dir); + $cloned_context->set('scriptData', $this->getArguments()); + + $script->runScript($host_config, $cloned_context); + + $context->mergeResults($cloned_context); + + $shell->popWorkingDir(); + } + +} \ No newline at end of file diff --git a/src/Artifact/Actions/Ftp/ExcludeAction.php b/src/Artifact/Actions/Ftp/ExcludeAction.php new file mode 100644 index 00000000..d696543f --- /dev/null +++ b/src/Artifact/Actions/Ftp/ExcludeAction.php @@ -0,0 +1,32 @@ +getArguments(); + $existing = $context->getResult(self::FTP_SYNC_EXCLUDES, []); + $to_exclude = array_unique(array_merge( + $context->getConfigurationService()->getSetting('excludeFiles.ftpSync', []), + $existing, + $to_exclude + )); + $context->setResult(self::FTP_SYNC_EXCLUDES, $to_exclude); + } +} \ No newline at end of file diff --git a/src/Artifact/Actions/Git/ExcludeAction.php b/src/Artifact/Actions/Git/ExcludeAction.php new file mode 100644 index 00000000..e8b8bb87 --- /dev/null +++ b/src/Artifact/Actions/Git/ExcludeAction.php @@ -0,0 +1,32 @@ +get('outerShell', $host_config->shell()); + $target_dir = $context->get('targetDir', false); + + $shell->pushWorkingDir($target_dir); + foreach ($this->getArguments() as $argument) { + $shell->run(sprintf('#! git checkout %s', $argument)); + } + $shell->popWorkingDir(); + } +} diff --git a/src/Configuration/ConfigurationService.php b/src/Configuration/ConfigurationService.php index 607e2e18..b4bdfa83 100644 --- a/src/Configuration/ConfigurationService.php +++ b/src/Configuration/ConfigurationService.php @@ -479,6 +479,7 @@ private function validateHostConfig($config_name, $data) ? true : false, 'tmpFolder' => '/tmp', + 'rootFolder' => $this->getFabfilePath(), ]; if (empty($data['needs'])) { diff --git a/src/Method/ArtifactsBaseMethod.php b/src/Method/ArtifactsBaseMethod.php new file mode 100644 index 00000000..928756da --- /dev/null +++ b/src/Method/ArtifactsBaseMethod.php @@ -0,0 +1,179 @@ +isArray(self::PREFS_KEY, 'Please provide an artifact configuration'); + if (!empty($config[self::PREFS_KEY])) { + $service = new ValidationService($config[self::PREFS_KEY], $errors, sprintf( + 'host-config.%s.%s', $config['configName'], self::PREFS_KEY)); + $service->hasKeys([ + 'actions' => 'An artifact needs a list of actions', + ]); + foreach ($config[self::PREFS_KEY]['actions'] as $action_config) { + if (!isset($action_config['action'])) { + $errors->addError('unknown', 'action needs a name'); + } + else { + $action = ActionFactory::get($this->getName(), $action_config['action']); + $action->validateConfig($config, $action_config, $errors); + } + } + } + } + + protected function prepareDirectoriesAndStages(HostConfig $host_config, TaskContextInterface $context, array $stages) { + + if ($use_local_repository = $host_config[self::PREFS_KEY]['useLocalRepository']) { + $install_dir = $host_config['gitRootFolder']; + $stages = array_diff($stages, ['installCode']); + } else { + $install_dir = $host_config['tmpFolder'] . '/' . $host_config['configName'] . '-' . time(); + } + + $target_dir = $host_config['tmpFolder'] . '/' . $host_config['configName'] . '-target-' . time(); + + $context->set('useLocalRepository', $use_local_repository); + $context->set('installDir', $install_dir); + $context->set('targetDir', $target_dir); + + return $stages; + } + + /** + * Build the artifact into given directory. + * + * @param HostConfig $host_config + * @param TaskContextInterface $context + * @param ShellProviderInterface $shell + * @param string $install_dir + * @param array $stages + * @throws MethodNotFoundException + * @throws TaskNotFoundInMethodException + * @throws MissingScriptCallbackImplementation + */ + protected function buildArtifact( + HostConfig $host_config, + TaskContextInterface $context, + ShellProviderInterface $shell, + string $install_dir, + array $stages + ) + { + $cloned_host_config = clone $host_config; + $keys = ['rootFolder', 'composerRootFolder', 'gitRootFolder']; + foreach ($keys as $key) { + $cloned_host_config[$key] = $install_dir; + } + $shell->cd($cloned_host_config['tmpFolder']); + $context->set('outerShell', $shell); + $context->set('installDir', $install_dir); + + AppDefaultStages::executeStages( + $context->getConfigurationService()->getMethodFactory(), + $cloned_host_config, + $stages, + 'appCreate', + $context, + 'Building artifact' + ); + + $this->runDeployScript($cloned_host_config, $context); + } + + protected function runStageSteps(HostConfig $host_config, TaskContextInterface $context, $implementations) + { + if (!$current_stage = $context->get('currentStage', false)) { + throw new \InvalidArgumentException('Missing currentStage on context!'); + } + + $implementations = array_merge( + [ + 'runDeployScript', + 'runActions', + ], + $implementations + ); + + if (in_array($current_stage, $implementations)) { + if (method_exists($this, $current_stage)) { + $this->{$current_stage}($host_config, $context); + } else { + throw new \RuntimeException(sprintf('Missing or unimplemented stage `%s`', $current_stage)); + } + } + } + + /** + * @param HostConfig $host_config + * @param TaskContextInterface $context + * @throws MethodNotFoundException + * @throws MissingScriptCallbackImplementation + */ + protected function runDeployScript(HostConfig $host_config, TaskContextInterface $context) + { + /** @var ScriptMethod $script_method */ + $script_method = $context->getConfigurationService()->getMethodFactory()->getMethod('script'); + $install_dir = $context->get('installDir'); + $context->set('variables', [ + 'installFolder' => $install_dir, + ]); + $context->set('rootFolder', $install_dir); + $script_method->runTaskSpecificScripts($host_config, 'deploy', $context); + + $context->setResult('skipResetStep', true); + } + + + public function runActions(HostConfig $host_config, TaskContextInterface $context) { + + $actions = $host_config[self::PREFS_KEY]['actions']; + foreach ($actions as $action_config) { + $action = ActionFactory::get($this->getname(), $action_config['action']); + $action->setArguments($action_config['arguments']); + $action->run($host_config, $context); + } + } +} diff --git a/src/Method/ArtifactsFtpMethod.php b/src/Method/ArtifactsFtpMethod.php new file mode 100644 index 00000000..91393e66 --- /dev/null +++ b/src/Method/ArtifactsFtpMethod.php @@ -0,0 +1,207 @@ +getName(), 'exclude', ExcludeAction::class ); + } + + public function getName(): string + { + return 'artifacts--ftp'; + } + + public function supports(string $method_name): bool + { + return in_array($method_name, array('ftp-sync', $this->getName())); + } + + public function getDefaultConfig(ConfigurationService $configuration_service, array $host_config): array + { + $return = parent::getDefaultConfig($configuration_service, $host_config); + $return['tmpFolder'] = '/tmp'; + $return['executables'] = [ + 'lftp' => 'lftp', + ]; + + $return['deployMethod'] = $this->getName(); + $return[self::PREFS_KEY] = [ + 'useLocalRepository' => false, + 'port' => 21, + 'lftpOptions' => [ + '--verbose=1', + '--no-perms', + '--no-symlinks', + '-P 20', + ], + 'actions' => [ + [ + 'action' => 'copy', + 'arguments' => [ + 'from' => '*', + 'to' => '.' + ], + ], + [ + 'action' => 'exclude', + 'arguments' => [ + '.git/', + 'node_modules/', + '.fabfile.yaml', + '.projectCreated.yaml', + 'fabfile.yaml' + ], + ] + ], + ]; + + return $return; + } + + /** + * @param array $config + * @param ValidationErrorBagInterface $errors + */ + public function validateConfig(array $config, ValidationErrorBagInterface $errors) + { + parent::validateConfig($config, $errors); + + if (in_array('drush', $config['needs'])) { + $errors->addError('needs', sprintf('The method `%s` is incompatible with the `drush`-method!', $this->getName())); + } + if ($config['deployMethod'] !== $this->getName()) { + $errors->addError('deployMethod', sprintf('deployMethod must be `%s`!', $this->getName())); + } + if (in_array('ftp-sync', $config['needs'])) { + $errors->addWarning('needs', sprintf('`ftp-sync` is deprecated, please use `%s`', $this->getName())); + } + if (isset($config['ftp'])) { + $errors->addError('ftp', sprintf('`ftp` is deprecated, please use `%s` instead!', self::PREFS_KEY)); + } + + if (!empty($config[self::PREFS_KEY])) { + $service = new ValidationService($config[self::PREFS_KEY], $errors, sprintf( + 'host-config.%s.%s', $config['configName'], self::PREFS_KEY)); + $service->hasKeys([ + 'user' => 'the ftp user-name', + 'host' => 'the ftp host to connect to', + 'port' => 'the port to connect to', + 'rootFolder' => 'the rootfolder of your app on the remote file-system', + ]); + $service->checkForValidFolderName('rootFolder'); + } + } + + /** + * @param HostConfig $host_config + * @param TaskContextInterface $context + * @throws MethodNotFoundException + * @throws MissingScriptCallbackImplementation + * @throws TaskNotFoundInMethodException + */ + public function deploy(HostConfig $host_config, TaskContextInterface $context) + { + if ($host_config['deployMethod'] !== $this->getName()) { + return; + } + + if (empty($host_config[self::PREFS_KEY]['password'])) { + $ftp = $host_config[self::PREFS_KEY]; + $ftp['password'] = $context->getPasswordManager()->getPasswordFor($ftp['host'], $ftp['port'], $ftp['user']); + $host_config[self::PREFS_KEY] = $ftp; + } + + $stages = $context->getConfigurationService()->getSetting( 'appStages.artifacts.ftp', self::STAGES ); + $stages = $this->prepareDirectoriesAndStages($host_config, $context, $stages); + + $shell = $this->getShell($host_config, $context); + $install_dir = $context->get('installDir'); + $target_dir = $context->get('targetDir'); + + $shell->run(sprintf('mkdir -p %s', $target_dir)); + + $this->buildArtifact($host_config, $context, $shell, $install_dir, $stages); + + $shell->run(sprintf('rm -rf %s', $install_dir)); + $shell->run(sprintf('rm -rf %s', $target_dir)); + + // Do not run any next tasks. + $context->setResult('runNextTasks', []); + } + + /** + * @param HostConfig $host_config + * @param TaskContextInterface $context + */ + public function appCreate(HostConfig $host_config, TaskContextInterface $context) + { + $this->runStageSteps($host_config, $context, [ + 'syncToFtp' + ]); + } + + /** + * @param HostConfig $host_config + * @param TaskContextInterface $context + */ + protected function syncToFtp(HostConfig $host_config, TaskContextInterface $context) + { + $shell = $this->getShell($host_config, $context); + $target_dir = $context->get('targetDir', false); + $exclude = $context->getResult(ExcludeAction::FTP_SYNC_EXCLUDES, []); + $options = implode(' ', $host_config[self::PREFS_KEY]['lftpOptions']); + if (count($exclude)) { + $options .= ' --exclude ' . implode(' --exclude ', $exclude); + } + + // Now we can sync the files via FTP. + $command_file = $host_config['tmpFolder'] . '/lftp_commands_' . time() . '.x'; + $shell->run(sprintf('touch %s', $command_file)); + $shell->run(sprintf( + "echo 'open -u %s,%s -p%s %s' >> %s", + $host_config[self::PREFS_KEY]['user'], + $host_config[self::PREFS_KEY]['password'], + $host_config[self::PREFS_KEY]['port'], + $host_config[self::PREFS_KEY]['host'], + $command_file + )); + $shell->run(sprintf( + 'echo "mirror %s -c -e -R %s %s" >> %s', + $options, + $target_dir, + $host_config[self::PREFS_KEY]['rootFolder'], + $command_file + )); + + $shell->run(sprintf('echo "exit" >> %s', $command_file)); + + $shell->run(sprintf('#!lftp -f %s', $command_file)); + + // Cleanup. + $shell->run(sprintf('rm %s', $command_file)); + } +} diff --git a/src/Method/ArtifactsGitMethod.php b/src/Method/ArtifactsGitMethod.php new file mode 100644 index 00000000..7e29c82a --- /dev/null +++ b/src/Method/ArtifactsGitMethod.php @@ -0,0 +1,284 @@ +getName(), 'exclude', ExcludeAction::class); + } + /** + * @return string + */ + public function getName(): string + { + return 'artifacts--git'; + } + + /** + * @param string $method_name + * @return bool + */ + public function supports(string $method_name): bool + { + return $method_name === $this->getName(); + } + + /** + * Get global settings + */ + public function getGlobalSettings(): array + { + $defaults = parent::getGlobalSettings(); + $defaults['excludeFiles']['gitSync'] = [ + 'fabfile.yaml', + '.fabfile.yaml', + '.git', + ]; + + return $defaults; + } + + /** + * Get default config. + * + * @param ConfigurationService $configuration_service + * @param array $host_config + * @return array + */ + public function getDefaultConfig(ConfigurationService $configuration_service, array $host_config): array + { + $return = parent::getDefaultConfig($configuration_service, $host_config); + $return['tmpFolder'] = '/tmp'; + $return['executables'] = [ + 'git' => 'git', + ]; + $return[self::PREFS_KEY] =[ + 'targetBranch' => 'build', + 'useLocalRepository' => false, + 'actions' => [ + [ + 'action' => 'copy', + 'arguments' => [ + 'to' => '.', + 'from' => '*', + ], + ], + [ + 'action' => 'delete', + 'arguments' => [ + '.fabfile.yaml', + 'fabfile.yaml', + '.projectsCreated' + ], + ], + ], + ]; + + $return['deployMethod'] = 'git-sync'; + + return $return; + } + + /** + * Validate config. + * + * @param array $config + * @param ValidationErrorBagInterface $errors + */ + public function validateConfig(array $config, ValidationErrorBagInterface $errors) + { + parent::validateConfig($config, $errors); + if ($config['deployMethod'] !== 'git-sync') { + $errors->addError('deployMethod', 'deployMethod must be `git-sync`!'); + } + $service = new ValidationService($config[self::PREFS_KEY], $errors, 'gitSync config'); + $service->hasKey('branch', 'gitSync needs a target branch to push build artifacts to!'); + $service->hasKey('repository', 'gitSync needs a target repository to push build artifacts to!'); + } + + /** + * @param HostConfig $host_config + * @param TaskContextInterface $context + * @throws MethodNotFoundException + * @throws MissingScriptCallbackImplementation + * @throws TaskNotFoundInMethodException + */ + public function deploy(HostConfig $host_config, TaskContextInterface $context) + { + if ($host_config['deployMethod'] !== 'git-sync') { + return ; + } + + $stages = $context->getConfigurationService()->getSetting('appStages.artifacts.git', self::STAGES); + $stages = $this->prepareDirectoriesAndStages($host_config, $context, $stages); + + $shell = $this->getShell($host_config, $context); + $install_dir = $context->get('installDir'); + $target_dir = $context->get('targetDir'); + + $this->buildArtifact($host_config, $context, $shell, $install_dir, $stages); + + $shell->run(sprintf('rm -rf %s', $target_dir)); + + if (!$context->get('useLocalRepository')) { + $shell->run(sprintf('rm -rf %s', $install_dir)); + } + } + + /** + * @param HostConfig $host_config + * @param TaskContextInterface $context + */ + public function appCreate(HostConfig $host_config, TaskContextInterface $context) + { + $this->runStageSteps($host_config, $context, [ + 'getSourceCommitInfo', + 'pullTargetRepository', + 'pushToTargetRepository', + ]); + } + + /** + * Return the git method. + * + * @param TaskContextInterface $context + * @return GitMethod + * @throws MethodNotFoundException + */ + private function getGitMethod(TaskContextInterface $context) + { + return $context->getConfigurationService()->getMethodFactory()->getMethod('git'); + } + + /** + * Get current tag and commit-hash from source repo. + * + * @param HostConfig $host_config + * @param TaskContextInterface $context + * @throws MethodNotFoundException + */ + protected function getSourceCommitInfo(HostConfig $host_config, TaskContextInterface $context) + { + if (!$git_method = $this->getGitMethod($context)) { + return; + } + + $tag = $git_method->getVersion($host_config, $context); + $hash = $git_method->getCommitHash($host_config, $context); + + // We need to store the commit-data as result, otherwise the won't persist. + $context->setResult('commitMessage', sprintf("Commit build artifact for tag %s [%s]", $tag, $hash)); + $context->setResult('commitHash', $hash); + } + + /** + * Pull target repository and find last source commit hash in log. + * + * @param HostConfig $host_config + * @param TaskContextInterface $context + */ + protected function pullTargetRepository(HostConfig $host_config, TaskContextInterface $context) + { + $target_dir = $context->get('targetDir', false); + $target_branch = $host_config[self::PREFS_KEY]['branch']; + $target_repository = $host_config[self::PREFS_KEY]['repository']; + + /** @var ShellProviderInterface $shell */ + $shell = $context->get('outerShell', $host_config->shell()); + $shell->run(sprintf('#!git clone --depth 30 -b %s %s %s', $target_branch, $target_repository, $target_dir)); + $shell->pushWorkingDir($target_dir); + $log = $shell->run('#!git log --format="%H|%s"', true); + $found = false; + foreach ($log->getOutput() as $line) { + list(, $subject) = explode('|', $line); + if (preg_match('/\[[0-9a-f]{5,40}\]/', $subject, $result)) { + $found = substr($result[0], 1, strlen($result[0]) - 2); + break; + } + } + $context->setResult('lastArtifactCommitHash', $found); + $shell->popWorkingDir(); + } + + /** + * @param HostConfig $host_config + * @param TaskContextInterface $context + */ + protected function pushToTargetRepository(HostConfig $host_config, TaskContextInterface $context) + { + $shell = $context->get('outerShell', $host_config->shell()); + $target_dir = $context->get('targetDir', false); + $message = $context->getResult('commitMessage', 'Commit build artifact'); + $detailed_message = $context->getResult('detailedCommitMessage', ''); + + if ($last_commit_hash = $context->getResult('lastArtifactCommitHash')) { + $current_commit_hash = $context->getResult('commitHash', 'HEAD'); + $detailed_message = $this->getSourceGitLog($shell, $context, $last_commit_hash, $current_commit_hash); + } + + /** @var ShellProviderInterface $shell */ + $shell->pushWorkingDir($target_dir); + + $shell->run('#!git add -A .'); + $shell->run(sprintf('#!git commit -m "%s" -m "%s" || true', $message, implode('" -m "', $detailed_message))); + $shell->run('#!git push origin'); + + $shell->popWorkingDir(); + } + + + + + /** + * @param ShellProviderInterface $shell + * @param TaskContextInterface $context + * @param $last_commit_hash + * @param $current_commit_hash + * @return array + */ + private function getSourceGitLog( + ShellProviderInterface $shell, + TaskContextInterface $context, + $last_commit_hash, + $current_commit_hash + ) { + $install_dir = $context->get('installDir', false); + $shell->pushWorkingDir($install_dir); + + $log = $shell->run(sprintf('#!git log %s..%s --oneline', $last_commit_hash, $current_commit_hash), true); + + $shell->popWorkingDir(); + return $log->getOutput(); + } +} diff --git a/src/Method/ComposerMethod.php b/src/Method/ComposerMethod.php index 61bf8367..82c19170 100644 --- a/src/Method/ComposerMethod.php +++ b/src/Method/ComposerMethod.php @@ -95,7 +95,7 @@ public function appCreate(HostConfig $host_config, TaskContextInterface $context throw new \InvalidArgumentException('Missing currentStage on context!'); } - if ($current_stage['stage'] == 'installDependencies') { + if ($current_stage == 'installDependencies') { $this->resetPrepare($host_config, $context); } } diff --git a/src/Method/DockerMethod.php b/src/Method/DockerMethod.php index afb487cb..8423854b 100644 --- a/src/Method/DockerMethod.php +++ b/src/Method/DockerMethod.php @@ -532,10 +532,10 @@ public function runAppSpecificTask(HostConfig $host_config, TaskContextInterface $docker_config = $this->getDockerConfig($host_config, $context->getConfigurationService()); $shell = $docker_config->shell(); - if (isset($docker_config['tasks'][$current_stage['stage']]) || - in_array($current_stage['stage'], array('spinUp', 'spinDown', 'deleteContainer')) + if (isset($docker_config['tasks'][$current_stage]) || + in_array($current_stage, array('spinUp', 'spinDown', 'deleteContainer')) ) { - $this->runTaskImpl($host_config, $context, $current_stage['stage'], false); + $this->runTaskImpl($host_config, $context, $current_stage, false); } } diff --git a/src/Method/DrushMethod.php b/src/Method/DrushMethod.php index 40324a1b..a84601d2 100644 --- a/src/Method/DrushMethod.php +++ b/src/Method/DrushMethod.php @@ -612,7 +612,7 @@ public function appCreate(HostConfig $host_config, TaskContextInterface $context if (!$current_stage = $context->get('currentStage', false)) { throw new \InvalidArgumentException('Missing currentStage on context!'); } - if ($current_stage['stage'] === 'install') { + if ($current_stage === 'install') { $this->waitForDatabase($host_config, $context); $this->install($host_config, $context); } diff --git a/src/Method/FtpSyncMethod.php b/src/Method/FtpSyncMethod.php deleted file mode 100644 index a6322451..00000000 --- a/src/Method/FtpSyncMethod.php +++ /dev/null @@ -1,199 +0,0 @@ - 'filesFolder', - 'private' => 'privateFilesFolder' - ]; - - public function getName(): string - { - return 'ftp-sync'; - } - - public function supports(string $method_name): bool - { - return $method_name === $this->getName(); - } - - public function getGlobalSettings(): array - { - $defaults = parent::getGlobalSettings(); - $defaults['excludeFiles']['ftpSync'] = [ - '.git/', - 'node_modules/', - 'fabfile.yaml' - ]; - - return $defaults; - } - - public function getDefaultConfig(ConfigurationService $configuration_service, array $host_config): array - { - $return = parent::getDefaultConfig($configuration_service, $host_config); - $return['tmpFolder'] = '/tmp'; - $return['executables'] = [ - 'lftp' => 'lftp', - ]; - - $return['deployMethod'] = 'ftp-sync'; - $return['ftp'] = [ - 'port' => 21, - 'lftpOptions' => [ - '--verbose=1', - '--no-perms', - '--no-symlinks', - '-P 20', - ] - ]; - - return $return; - } - - public function validateConfig(array $config, ValidationErrorBagInterface $errors) - { - parent::validateConfig($config, $errors); - if (in_array('drush', $config['needs'])) { - $errors->addError('needs', 'The method `ftp-sync` is incompatible with the `drush`-method!'); - } - if ($config['deployMethod'] !== 'ftp-sync') { - $errors->addError('deployMethod', 'deployMethod must be `ftp-sync`!'); - } - $service = new ValidationService($config, $errors, 'Host-config '. $config['configName']); - $service->isArray('ftp', 'Please provide ftp-credentials!'); - if (!empty($config['ftp'])) { - $service = new ValidationService($config['ftp'], $errors, 'host-config.ftp '. $config['configName']); - $service->hasKeys([ - 'user' => 'the ftp user-name', - 'host' => 'the ftp host to connect to', - 'port' => 'the port to connect to', - 'rootFolder' => 'the rootfolder of your app on the remote file-system', - ]); - $service->checkForValidFolderName('rootFolder'); - } - } - - /** - * @param HostConfig $host_config - * @param TaskContextInterface $context - * @param ShellProviderInterface $shell - * @param string $install_dir - * @throws \Phabalicious\Exception\MethodNotFoundException - * @throws \Phabalicious\Exception\MissingScriptCallbackImplementation - * @throws \Phabalicious\Exception\TaskNotFoundInMethodException - */ - protected function createAppCode( - HostConfig $host_config, - TaskContextInterface $context, - ShellProviderInterface $shell, - string $install_dir - ) { - // First, create an app in a temporary-folder. - $stages = $context->getConfigurationService()->getSetting( - 'appStages.createCode', - AppDefaultStages::CREATE_CODE - ); - - $cloned_host_config = clone $host_config; - $keys = ['rootFolder', 'composerRootFolder', 'gitRootFolder']; - foreach ($keys as $key) { - $cloned_host_config[$key] = $install_dir; - } - $shell->cd($cloned_host_config['tmpFolder']); - $context->set('outerShell', $shell); - - AppDefaultStages::executeStages( - $context->getConfigurationService()->getMethodFactory(), - $cloned_host_config, - $stages, - 'appCreate', - $context, - 'Creating code' - ); - - // Run deploy scripts - /** @var ScriptMethod $script_method */ - $script_method = $context->getConfigurationService()->getMethodFactory()->getMethod('script'); - $context->set('variables', [ - 'installFolder' => $install_dir - ]); - $context->set('rootFolder', $install_dir); - $script_method->runTaskSpecificScripts($cloned_host_config, 'deploy', $context); - - $context->setResult('skipResetStep', true); - } - /** - * @param HostConfig $host_config - * @param TaskContextInterface $context - * @throws \Phabalicious\Exception\MethodNotFoundException - * @throws \Phabalicious\Exception\MissingScriptCallbackImplementation - * @throws \Phabalicious\Exception\TaskNotFoundInMethodException - */ - public function deploy(HostConfig $host_config, TaskContextInterface $context) - { - if ($host_config['deployMethod'] !== 'ftp-sync') { - return; - } - - if (empty($host_config['ftp']['password'])) { - $ftp = $host_config['ftp']; - $ftp['password'] = $context->getPasswordManager()->getPasswordFor($ftp['host'], $ftp['port'], $ftp['user']); - $host_config['ftp'] = $ftp; - } - - $install_dir = $host_config['tmpFolder'] . '/' . $host_config['configName'] . '-' . time(); - $context->set('installDir', $install_dir); - - $shell = $this->getShell($host_config, $context); - $this->createAppCode($host_config, $context, $shell, $install_dir); - - $exclude = $context->getConfigurationService()->getSetting('excludeFiles.ftpSync', []); - $options = implode(' ', $host_config['ftp']['lftpOptions']); - if (count($exclude)) { - $options .= ' --exclude ' . implode(' --exclude ', $exclude); - } - - // Now we can sync the files via FTP. - $command_file = $host_config['tmpFolder'] . '/lftp_commands_' . time() . '.x'; - $shell->run(sprintf('touch %s', $command_file)); - $shell->run(sprintf( - "echo 'open -u %s,%s -p%s %s' >> %s", - $host_config['ftp']['user'], - $host_config['ftp']['password'], - $host_config['ftp']['port'], - $host_config['ftp']['host'], - $command_file - )); - $shell->run(sprintf( - 'echo "mirror %s -c -e -R %s %s" >> %s', - $options, - $install_dir, - $host_config['ftp']['rootFolder'], - $command_file - )); - - $shell->run(sprintf('echo "exit" >> %s', $command_file)); - - $shell->run(sprintf('#!lftp -f %s', $command_file)); - - // Cleanup. - $shell->run(sprintf('rm %s', $command_file)); - $shell->run(sprintf('rm -rf %s', $install_dir)); - - // Do not run any next tasks. - $context->setResult('runNextTasks', []); - } -} diff --git a/src/Method/GitMethod.php b/src/Method/GitMethod.php index af950aae..941944bb 100644 --- a/src/Method/GitMethod.php +++ b/src/Method/GitMethod.php @@ -155,7 +155,7 @@ public function appCreate(HostConfig $host_config, TaskContextInterface $context throw new \InvalidArgumentException('Missing currentStage on context!'); } - if ($current_stage['stage'] !== 'installCode') { + if ($current_stage !== 'installCode') { return; } /** @var ShellProviderInterface $shell */ diff --git a/src/Method/ScriptMethod.php b/src/Method/ScriptMethod.php index 34865ad4..e5c54db6 100644 --- a/src/Method/ScriptMethod.php +++ b/src/Method/ScriptMethod.php @@ -82,6 +82,10 @@ public function runScript(HostConfig $host_config, TaskContextInterface $context $environment = Utilities::mergeData($environment, $host_config['environment']); } $variables = Utilities::mergeData($variables, [ + 'context' => [ + 'data' => $context->getData(), + 'results' => $context->getResults(), + ], 'host' => $host_config->raw(), 'settings' => $context->getConfigurationService() ->getAllSettings(['hosts', 'dockerHosts']), @@ -365,6 +369,10 @@ public function preflightTask(string $task, HostConfig $config, TaskContextInter { parent::preflightTask($task, $config, $context); $this->runTaskSpecificScripts($config, $task . 'Prepare', $context); + if ($current_stage = $context->get('currentStage')) { + $current_stage = ucfirst($current_stage); + $this->runTaskSpecificScripts($config, $task . $current_stage . 'Prepare', $context); + } } /** @@ -385,6 +393,11 @@ public function postflightTask(string $task, HostConfig $config, TaskContextInte if (empty($this->handledTaskSpecificScripts[$task])) { $this->runTaskSpecificScripts($config, $task, $context); } + if ($current_stage = $context->get('currentStage')) { + $current_stage = ucfirst($current_stage); + $this->runTaskSpecificScripts($config, $task . $current_stage . 'Finished', $context); + } + $this->runTaskSpecificScripts($config, $task . 'Finished', $context); foreach ([$task . 'Prepare', $task, $task . 'Finished'] as $t) { diff --git a/src/Method/TaskContext.php b/src/Method/TaskContext.php index 70a20691..54991e20 100644 --- a/src/Method/TaskContext.php +++ b/src/Method/TaskContext.php @@ -57,6 +57,11 @@ public function get(string $key, $default = null) return isset($this->data[$key]) ? $this->data[$key] : $default; } + public function getData(): array + { + return $this->data; + } + public function setOutput(OutputInterface $output) { $this->output = $output; diff --git a/src/Method/TaskContextInterface.php b/src/Method/TaskContextInterface.php index cf1a4ae3..0799dfa8 100644 --- a/src/Method/TaskContextInterface.php +++ b/src/Method/TaskContextInterface.php @@ -18,6 +18,8 @@ public function set(string $key, $data); public function get(string $key, $default = null); + public function getData(): array; + public function setInput(InputInterface $input); public function getInput(): InputInterface; diff --git a/src/Utilities/AppDefaultStages.php b/src/Utilities/AppDefaultStages.php index 64a05e21..9bddb4a8 100644 --- a/src/Utilities/AppDefaultStages.php +++ b/src/Utilities/AppDefaultStages.php @@ -8,46 +8,22 @@ class AppDefaultStages { const CREATE = [ - [ - 'stage' => 'prepareDestination', - ], - [ - 'stage' => 'installCode', - ], - [ - 'stage' => 'spinUp', - ], - [ - 'stage' => 'installDependencies', - ], - [ - 'stage' => 'install', - ], + 'prepareDestination', + 'installCode', + 'spinUp', + 'installDependencies', + 'install', ]; const DEPLOY = [ - [ - 'stage' => 'spinUp', - ] + 'spinUp', ]; const DESTROY = [ - [ - 'stage' => 'spinDown', - ], - [ - 'stage' => 'deleteContainer', - ], + 'spinDown', + 'deleteContainer', ]; - const CREATE_CODE = [ - [ - 'stage' => 'installCode', - ], - [ - 'stage' => 'installDependencies', - ], - ]; /** @@ -69,7 +45,7 @@ public static function executeStages( string $message ) { foreach ($stages as $stage) { - $context->getOutput()->writeln(sprintf('%s, stage %s', $message, $stage['stage'])); + $context->io()->comment(sprintf('%s, stage %s', $message, $stage)); $context->set('currentStage', $stage); $method_factory->runTask($command, $host_config, $context); } diff --git a/src/Utilities/Utilities.php b/src/Utilities/Utilities.php index 5d17a1bc..7576f4e3 100644 --- a/src/Utilities/Utilities.php +++ b/src/Utilities/Utilities.php @@ -5,7 +5,7 @@ class Utilities { - const FALLBACK_VERSION = '3.0.21'; + const FALLBACK_VERSION = '3.2.0'; public static function mergeData(array $data, array $override_data): array { @@ -44,6 +44,10 @@ private static function expandVariablesImpl(string $prefix, array $variables, ar foreach ($variables as $key => $value) { if (is_array($value)) { self::expandVariablesImpl($prefix . '.' . $key, $value, $result); + } elseif (is_object($value)) { + if (method_exists($value, '__toString')) { + $result["%$prefix.$key%"] = (string) ($value); + } } else { $result["%$prefix.$key%"] = (string) ($value); }