├── .php_cs ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── doc ├── configuration.md ├── custom-deployer.md ├── default-deployer.md ├── getting-started.md ├── installation.md └── tutorials │ ├── local-ssh-config.md │ ├── remote-code-cloning.md │ └── remote-ssh-config.md ├── phpunit.xml.dist ├── src ├── Command │ ├── DeployCommand.php │ └── RollbackCommand.php ├── Configuration │ ├── AbstractConfiguration.php │ ├── ConfigurationAdapter.php │ ├── CustomConfiguration.php │ ├── DefaultConfiguration.php │ └── Option.php ├── Context.php ├── DependencyInjection │ └── EasyDeployExtension.php ├── Deployer │ ├── AbstractDeployer.php │ ├── CustomDeployer.php │ └── DefaultDeployer.php ├── EasyDeployBundle.php ├── Exception │ ├── InvalidConfigurationException.php │ └── ServerConfigurationException.php ├── Helper │ ├── Str.php │ └── SymfonyConfigPathGuesser.php ├── Logger.php ├── Requirement │ ├── AbstractRequirement.php │ ├── AllowsLoginViaSsh.php │ └── CommandExists.php ├── Resources │ ├── config │ │ └── services.xml │ └── skeleton │ │ └── deploy.php.dist ├── Server │ ├── Property.php │ ├── Server.php │ └── ServerRepository.php └── Task │ ├── Task.php │ ├── TaskCompleted.php │ └── TaskRunner.php └── tests ├── Configuration ├── ConfigurationAdapterTest.php └── DefaultConfigurationTest.php ├── ContextTest.php ├── Helper └── StrTest.php ├── Server ├── ServerRepositoryTest.php └── ServerTest.php └── Task └── TaskCompletedTest.php /.php_cs: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->ignoreDotFiles(true) 6 | ->ignoreVCS(true) 7 | ->exclude(array('doc', 'vendor')) 8 | ->files() 9 | ->name('*.php') 10 | ; 11 | 12 | return PhpCsFixer\Config::create() 13 | ->setUsingCache(true) 14 | ->setRiskyAllowed(true) 15 | ->setFinder($finder) 16 | ->setRules([ 17 | '@Symfony' => true, 18 | 'array_syntax' => ['syntax' => 'short'], 19 | 'binary_operator_spaces' => array( 20 | 'align_double_arrow' => false, 21 | ), 22 | 'combine_consecutive_unsets' => true, 23 | 'no_useless_else' => true, 24 | 'no_useless_return' => true, 25 | 'ordered_imports' => true, 26 | 'phpdoc_summary' => false, 27 | 'strict_comparison' => true, 28 | ]) 29 | ; 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | sudo: false 4 | 5 | git: 6 | depth: 1 7 | 8 | cache: 9 | directories: 10 | - $HOME/.composer/cache/files 11 | 12 | matrix: 13 | fast_finish: true 14 | include: 15 | - php: nightly 16 | env: 17 | - SYMFONY_VERSION="dev-master" 18 | - CHECK_PHP_SYNTAX="no" 19 | - php: 7.2 20 | env: 21 | - SYMFONY_VERSION="3.4.*" 22 | - CHECK_PHP_SYNTAX="yes" 23 | - php: 7.2 24 | env: 25 | - SYMFONY_VERSION="4.4.*" 26 | - CHECK_PHP_SYNTAX="yes" 27 | - php: 7.2 28 | env: 29 | - SYMFONY_VERSION="5.0.*" 30 | - CHECK_PHP_SYNTAX="yes" 31 | allow_failures: 32 | - php: nightly 33 | 34 | before_install: 35 | - stty cols 120 36 | - INI_FILE=~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini 37 | - echo memory_limit = -1 >> $INI_FILE 38 | - echo session.gc_probability = 0 >> $INI_FILE 39 | - echo opcache.enable_cli = 1 >> $INI_FILE 40 | - rm -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini 41 | - composer self-update 42 | - if [[ "$SYMFONY_VERSION" != "" ]]; then composer require "symfony/framework-bundle:${SYMFONY_VERSION}" --no-update; fi; 43 | 44 | install: 45 | - if [[ "$CHECK_PHP_SYNTAX" == "yes" ]]; then composer require --dev --no-update friendsofphp/php-cs-fixer; fi; 46 | - composer update --prefer-dist --no-interaction --no-suggest --no-progress --ansi 47 | 48 | script: 49 | - vendor/bin/phpunit 50 | - if [[ "$CHECK_PHP_SYNTAX" == "yes" ]]; then php vendor/bin/php-cs-fixer --no-interaction --dry-run --diff -v fix; fi; 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Javier Eguiluz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | EasyDeployBundle 2 | ================ 3 | 4 | **EasyDeployBundle is the easiest way to deploy your Symfony applications.** 5 | 6 | ### Features 7 | 8 | * Zero dependencies. No Python. No Ruby. No Capistrano. No Ansible. Nothing. 9 | * Zero configuration files. No YAML. No XML. No JSON. Just pure PHP awesomeness. 10 | * Multi-server and multi-stage deployment (e.g. "production", "staging", "qa"). 11 | * Zero downtime deployments. 12 | * Supports Symfony 2.7+, Symfony 3.x and Symfony 4.x applications. 13 | * Compatible with GitHub, BitBucket, GitLab and your own Git servers. 14 | 15 | ### Requirements 16 | 17 | * Your local machine: PHP 7.1 or higher and a SSH client. 18 | * Your remote servers: they allow SSH connections from the local machine. 19 | * Your application: it can use any version of Symfony (2.7+, 3.x, 4.x). 20 | 21 | ### Documentation 22 | 23 | * [Installation](doc/installation.md) 24 | * [Getting Started](doc/getting-started.md) 25 | * [Configuration](doc/configuration.md) 26 | * [Default Deployer](doc/default-deployer.md) 27 | * [Custom Deployer](doc/custom-deployer.md) 28 | 29 | #### Tutorials 30 | 31 | * [Creating a Local SSH Configuration File](doc/tutorials/local-ssh-config.md) 32 | * [Troubleshooting Connection Issues to Remote SSH Servers](doc/tutorials/remote-ssh-config.md) 33 | * [Cloning the Application Code on Remote Servers](doc/tutorials/remote-code-cloning.md) 34 | 35 | > **NOTE** 36 | > EasyDeploy does not "provision" servers (like installing a web server and the 37 | > right PHP version for your application); use Ansible if you need that. 38 | > EasyDeploy does not deploy containerized applications: use Kubernetes if you 39 | > need that. 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easycorp/easy-deploy-bundle", 3 | "type": "symfony-bundle", 4 | "description": "The easiest way to deploy Symfony applications", 5 | "keywords": ["deploy", "deployment", "deployer"], 6 | "homepage": "https://github.com/EasyCorp/easy-deploy-bundle", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Javier Eguiluz", 11 | "email": "javiereguiluz@gmail.com" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=7.2.0", 16 | "symfony/console": "~2.3|~3.0|~4.0|~5.0", 17 | "symfony/dependency-injection": "~2.3|~3.0|~4.0|~5.0", 18 | "symfony/expression-language": "~2.4|~3.0|~4.0|~5.0", 19 | "symfony/filesystem": "~2.3|~3.0|~4.0|~5.0", 20 | "symfony/http-foundation": "~2.3|~3.0|~4.0|~5.0", 21 | "symfony/http-kernel": "~2.3|~3.0|~4.0|~5.0", 22 | "symfony/polyfill-mbstring": "^1.3", 23 | "symfony/process": "~2.3|~3.0|~4.0|~5.0" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^6.1" 27 | }, 28 | "config": { 29 | "sort-packages": true 30 | }, 31 | "autoload": { 32 | "psr-4": { "EasyCorp\\Bundle\\EasyDeployBundle\\": "src/" } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { "EasyCorp\\Bundle\\EasyDeployBundle\\Tests\\": "tests/" } 36 | }, 37 | "extra": { 38 | "branch-alias": { 39 | "dev-master": "1.0.x-dev" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /doc/configuration.md: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | Deployment Strategies 5 | --------------------- 6 | 7 | There are a lot of different ways to deploy a Symfony application. This bundle 8 | provides two *deployers* that implement different strategies: 9 | 10 | * **Default Deployer**, it's the same zero-downtime strategy implemented by 11 | tools like Capistrano, Capifony and Deployer PHP. 12 | * **Custom Deployer**, it's a strategy that assumes nothing about how you want 13 | to deploy your application. It's similar to Python's Fabric tool, so it's just 14 | an SSH toolkit instead of a deployer. 15 | 16 | There are plans to add more deployment strategies in the future. Open issues to 17 | ask for new strategies or vote for the existing issues so we can make better 18 | decisions about what to implement next. 19 | 20 | Configuration Files 21 | ------------------- 22 | 23 | EasyDeploy uses plain PHP files to configure the deployment process. In other 24 | words, you don't have to learn any special syntax and you won't face any of the 25 | problems and constraints imposed by XML, JSON and YAML files. 26 | 27 | The first time you run the `deploy` command in a Symfony application, an initial 28 | config file is created for you. Go ahead, run `deploy` and open the generated 29 | config file. This is what you'll see: 30 | 31 | ```php 32 | // app/config/deploy_prod.php 33 | use EasyCorp\Bundle\EasyDeployBundle\Deployer\DefaultDeployer; 34 | 35 | return new class extends DefaultDeployer 36 | { 37 | public function configure() 38 | { 39 | return $this->getConfigBuilder() 40 | ->server('user@hostname') 41 | ->deployDir('/var/www/symfony-demo') 42 | ->repositoryUrl('git@github.com:symfony/symfony-demo.git') 43 | ->repositoryBranch('master') 44 | ; 45 | } 46 | }; 47 | ``` 48 | 49 | The configuration file must return a PHP class extending the base class 50 | that corresponds to the deployment strategy used by your application. In PHP 7 51 | this is easy because you can create anonymous classes (`new class extends ...`). 52 | 53 | Then, configure the deployment process using the config builder object given to 54 | you in the `configure()` method. Each config builder is unique for the 55 | deployment strategy, so your IDE will only autocomplete the options available. 56 | 57 | That's the best part of using a PHP config file. You don't have to read any 58 | docs, you don't have to learn any syntax, you don't have to memorize any option, 59 | you don't have to check deprecated/new options. All the available and updated 60 | config options are given to you by the IDE autocompletion. Simple, smart, and 61 | convenient. 62 | 63 | Common Configuration Options 64 | ---------------------------- 65 | 66 | Most config options depend on the strategy used, but there are some options 67 | common to all of them. 68 | 69 | ### SSH Agent Forwarding 70 | 71 | [SSH agent forwarding][1] allows remote servers to use your local SSH keys. This 72 | lets remote servers *fool* other services and make them believe that is your 73 | local machine which is accessing those services. 74 | 75 | This option is enabled by default, but [some people][2] consider it harmful, so 76 | you can disable it as follows: 77 | 78 | ```php 79 | public function configure() 80 | { 81 | return $this->getConfigBuilder() 82 | ->useSshAgentForwarding(false) 83 | // ... 84 | ; 85 | } 86 | ``` 87 | 88 | ### Server Configuration 89 | 90 | **This is the most important option** and it defines the SSH connection 91 | credentials for all the servers involved in the deployment process. For simple 92 | applications where you only have one server, you'll define something like this: 93 | 94 | ```php 95 | public function configure() 96 | { 97 | return $this->getConfigBuilder() 98 | ->server('user@hostname') 99 | // ... 100 | ; 101 | } 102 | ``` 103 | 104 | The value of the `server()` option can be any string used to connect to the 105 | server via SSH (anything that you may type in the `ssh ...` console command): 106 | 107 | ```php 108 | // hostname (IP) and no user ('root' will be used) 109 | ->server('123.123.123.123') 110 | 111 | // user + hostname 112 | ->server('user@example.com') 113 | 114 | // user + host name + custom SSH port (default port: 22) 115 | ->server('user@example.com:22123') 116 | 117 | // no user or hostname/IP (credentials will be read from ~/.ssh/config file) 118 | ->server('production') 119 | ``` 120 | 121 | > **TIP** 122 | > 123 | > Adding the usernames, hostnames, IPs and port numbers of the servers is boring 124 | > and error prone. It's better to define that config in your local SSH config file. 125 | > [Read this tutorial][4] to learn how to do that. 126 | 127 | #### Multiple Servers 128 | 129 | If your application is deployed to several servers, add the `server()` option 130 | for each of those servers: 131 | 132 | ```php 133 | public function configure() 134 | { 135 | return $this->getConfigBuilder() 136 | ->server('deployer@hostname1') 137 | ->server('deployer@hostname2') 138 | // ... 139 | ; 140 | } 141 | ``` 142 | 143 | #### Server Roles 144 | 145 | By default, all configured servers are treated as the server where the Symfony 146 | app is deployed. However, for complex apps you may have servers with different 147 | responsibilities (workers, database servers, etc.). 148 | 149 | These responsibilities are called **roles**. There is one reserved role called 150 | **app** which is applied by default to all servers. You can define as many 151 | custom roles as needed passing an array with the role names as the second 152 | argument of the `server()` option: 153 | 154 | ```php 155 | public function configure() 156 | { 157 | return $this->getConfigBuilder() 158 | ->server('deployer@hostname1') // this server uses the default 'app' role 159 | ->server('deployer@hostname2', ['workers', 'worker-1']) 160 | ->server('deployer@hostname3', ['workers', 'worker-2']) 161 | ->server('deployer@hostname4', ['database']) 162 | // ... 163 | ; 164 | } 165 | ``` 166 | 167 | Later, these role names will let you run some deployment commands on specific 168 | servers. When using custom roles, don't forget to add the `app` role to those 169 | servers where the Symfony applications is deployed. For example, if you use the 170 | [blue/green deployment strategy][3], add the `app` role in addition to the 171 | `blue` and `green` ones: 172 | 173 | ```php 174 | public function configure() 175 | { 176 | return $this->getConfigBuilder() 177 | ->server('deployer@hostname1', ['app', 'blue']) 178 | ->server('deployer@hostname2', ['app', 'green']) 179 | // ... 180 | ; 181 | } 182 | ``` 183 | 184 | #### Server Properties 185 | 186 | These properties are custom configuration options defined for a particular 187 | server. You can define them as an associative array passed as the third argument 188 | of the `server()` option. Later you'll see how to use these properties inside 189 | the commands executed on any server: 190 | 191 | ```php 192 | public function configure() 193 | { 194 | return $this->getConfigBuilder() 195 | ->server('deployer@hostname1', ['app'], ['token' => '...']) 196 | ->server('deployer@hostname2', ['database'], ['use-lock' => false]) 197 | // ... 198 | ; 199 | } 200 | ``` 201 | 202 | Common Hooks 203 | ------------ 204 | 205 | The commands executed during a deployment and their order depends on the 206 | deployer used. However, all deployers include *hooks* that let you execute your 207 | own commands before, after or in the middle of that deployment flow. Technically 208 | these hooks are methods of the PHP class used to define the deployment. 209 | 210 | Each deployer defines its own hooks, but all of them define these common hooks: 211 | 212 | ```php 213 | use EasyCorp\Bundle\EasyDeployBundle\Deployer\DefaultDeployer; 214 | 215 | return new class extends DefaultDeployer 216 | { 217 | public function configure() 218 | { 219 | // ... 220 | } 221 | 222 | 223 | public function beforeStartingDeploy() 224 | { 225 | // Deployment hasn't started yet, so here you can execute commands 226 | // to prepare the application or the remote servers 227 | } 228 | 229 | public function beforeFinishingDeploy() 230 | { 231 | // Deployment has finished but the deployer hasn't finished its 232 | // execution yet. Here you can run some checks in the deployed app 233 | // or send notifications. 234 | } 235 | 236 | public function beforeCancelingDeploy() 237 | { 238 | // An error happened during the deployment and remote servers are 239 | // going to be reverted to their original state. Here you can perform 240 | // clean ups or send notifications about the error. 241 | } 242 | 243 | 244 | public function beforeStartingRollback() 245 | { 246 | // Rollback hasn't started yet, so here you can execute commands 247 | // to prepare the application or the remote servers. 248 | } 249 | 250 | public function beforeCancelingRollback() 251 | { 252 | // An error happened during the rollback and remote servers are 253 | // going to be reverted to their original state. Here you can perform 254 | // clean ups or send notifications about the error. 255 | } 256 | 257 | public function beforeFinishingRollback() 258 | { 259 | // Rollback has finished but the deployer hasn't finished its 260 | // execution yet. Here you can run some checks in the reverted app 261 | // or send notifications. 262 | } 263 | }; 264 | ``` 265 | 266 | Common Methods 267 | -------------- 268 | 269 | In addition to the common config options and hooks, every *deployer* has access 270 | to some common methods that are useful to deploy and roll back the application. 271 | 272 | ### `runLocal()` Method 273 | 274 | Executes the given shell command on the local computer. The working directory of 275 | the command is set to the local project root directory, so you don't have to add 276 | a `cd` command before the command: 277 | 278 | ```php 279 | public function beforeStartingDeploy() 280 | { 281 | $this->runLocal('./vendor/bin/simple-phpunit'); 282 | // equivalent to the following: 283 | // $this->runLocal('cd /path/to/project && ./vendor/bin/simple-phpunit'); 284 | } 285 | ``` 286 | 287 | If the deployer allows to configure the Symfony environment, it is automatically 288 | defined as an env var before executing the command: 289 | 290 | ```php 291 | public function beforeStartingDeploy() 292 | { 293 | $this->runLocal('./bin/console app:optimize-for-deploy'); 294 | // equivalent to the following: 295 | // $this->runLocal('SYMFONY_ENV=prod ./bin/console app:optimize-for-deploy'); 296 | } 297 | ``` 298 | 299 | If you need to change the Symfony environment for some command, add the `--env` 300 | option to the command, because it has preference over the env vars: 301 | 302 | ```php 303 | public function beforeStartingDeploy() 304 | { 305 | $this->runLocal('./bin/console app:optimize-for-deploy --env=dev'); 306 | // equivalent to the following (--env=dev wins over SYMFONY_ENV=prod): 307 | // $this->runLocal('SYMFONY_ENV=prod ./bin/console app:optimize-for-deploy --env=dev'); 308 | } 309 | ``` 310 | 311 | The `runLocal()` method returns an immutable object of type `TaskCompleted` 312 | which contains the command exit code, the full command output and other 313 | utilities: 314 | 315 | ```php 316 | public function beforeStartingDeploy() 317 | { 318 | $result = $this->runLocal('./bin/console app:optimize-for-deploy'); 319 | if (!$result->isSuccessful()) { 320 | $this->notify($result->getOutput()); 321 | } 322 | } 323 | ``` 324 | 325 | ### `runRemote()` Method 326 | 327 | Executes the given shell command on one or more remote servers. By default, 328 | remote commands are executed only on the servers with the role `app`. Pass an 329 | array of role names to execute the command on the servers that contain any of 330 | those roles: 331 | 332 | ```php 333 | public function beforeFinishingDeploy() 334 | { 335 | $this->runRemote('./bin/console app:generate-xml-sitemap'); 336 | $this->runRemote('/user/deployer/backup.sh', ['database']); 337 | $this->runRemote('/user/deployer/scripts/check.sh', ['app', 'workers']); 338 | } 339 | ``` 340 | 341 | The working directory of the command is set to the remote project root 342 | directory, so you don't have to add a `cd` command to that directory: 343 | 344 | ```php 345 | public function beforeFinishingDeploy() 346 | { 347 | $this->runRemote('./bin/console app:generate-xml-sitemap'); 348 | // equivalent to the following: 349 | // $this->runRemote('cd /path/to/project && ./bin/console app:generate-xml-sitemap'); 350 | } 351 | ``` 352 | 353 | If the deployer allows to configure the Symfony environment, it is automatically 354 | defined as an env var before executing the command: 355 | 356 | ```php 357 | public function beforeFinishingDeploy() 358 | { 359 | $this->runRemote('./bin/console app:generate-xml-sitemap'); 360 | // equivalent to the following: 361 | // $this->runRemote('SYMFONY_ENV=prod ./bin/console app:generate-xml-sitemap'); 362 | } 363 | ``` 364 | 365 | If you need to change the Symfony environment for some command, add the `--env` 366 | option to the command, because it has preference over the env vars: 367 | 368 | ```php 369 | public function beforeFinishingDeploy() 370 | { 371 | $this->runRemote('./bin/console app:generate-xml-sitemap --env=dev'); 372 | // equivalent to the following (--env=dev wins over SYMFONY_ENV=prod): 373 | // $this->runRemote('SYMFONY_ENV=prod ./bin/console app:generate-xml-sitemap --env=dev'); 374 | } 375 | ``` 376 | 377 | The `runRemote()` method returns an array of immutable objects of type 378 | `TaskCompleted` which contains the exit code, the full command output and other 379 | utilities for the execution of the command on each server: 380 | 381 | ```php 382 | public function beforeFinishingDeploy() 383 | { 384 | $results = $this->runRemote('./bin/console app:generate-xml-sitemap'); 385 | foreach ($results as $result) { 386 | $this->notify(sprintf('%d sitemaps on %s server', $result->getOutput(), $result->getServer())); 387 | } 388 | } 389 | ``` 390 | 391 | ### `log()` Method 392 | 393 | This method appends the given message to the log file generated for each 394 | deployment. If the deployment/rollback is run with the `-v` option, these 395 | messages are displayed on the screen too: 396 | 397 | ```php 398 | public function beforeFinishingDeploy() 399 | { 400 | $this->log('Generating the Google XML Sitemap'); 401 | $this->runRemote('./bin/console app:generate-xml-sitemap'); 402 | } 403 | ``` 404 | 405 | Command Properties 406 | ------------------ 407 | 408 | If you defined custom properties when configuring a server, you can use those 409 | inside a shell command with the `{{ property-name }}` syntax. For example, if 410 | you defined these servers: 411 | 412 | ```php 413 | public function configure() 414 | { 415 | return $this->getConfigBuilder() 416 | ->server('deployer@hostname1', ['app'], ['token' => '...']) 417 | ->server('deployer@hostname2', ['database'], ['use-lock' => false]) 418 | // ... 419 | ; 420 | } 421 | ``` 422 | 423 | Those properties can be part of any command run on those servers: 424 | 425 | ```php 426 | public function beforeFinishingDeploy() 427 | { 428 | $this->runRemote('./bin/console app:generate-xml-sitemap --token={{ token }}'); 429 | $this->runRemote('/user/deployer/backup.sh --lock-tables={{ use-lock }}', ['database']); 430 | } 431 | ``` 432 | 433 | [1]: https://developer.github.com/guides/using-ssh-agent-forwarding/ 434 | [2]: https://heipei.github.io/2015/02/26/SSH-Agent-Forwarding-considered-harmful/ 435 | [3]: https://martinfowler.com/bliki/BlueGreenDeployment.html 436 | [4]: tutorials/local-ssh-config.md 437 | -------------------------------------------------------------------------------- /doc/custom-deployer.md: -------------------------------------------------------------------------------- 1 | Custom Deployer 2 | =============== 3 | 4 | This is the deployment strategy that can be used when your deployment workflow 5 | doesn't fit the one proposed by the default deployer. This strategy assumes 6 | nothing about the deployment, so you must implement the entire deploy and 7 | rollback workflows. It's similar to using Python's Fabric tool. 8 | 9 | What You Need 10 | ------------- 11 | 12 | * **Local Machine**: 13 | * A Symfony application with the EasyDeploy bundle installed. 14 | * A SSH client executable via the `ssh` console command. 15 | * **Remote Server/s**: 16 | * A SSH server that accepts connections from your local machine. 17 | * **Symfony Application**: 18 | * No special requirements (it can use any Symfony version and its code 19 | can be stored anywhere). 20 | 21 | Configuration 22 | ------------- 23 | 24 | The custom deployer doesn't define any configuration option, so there's only 25 | the two options common to all deployers: `server()` to add remote servers and 26 | `useSshAgentForwarding()`. 27 | 28 | Execution Flow 29 | -------------- 30 | 31 | There are no special hooks defined for this deployer, so you can use the same 32 | hooks defined for all deployers: 33 | 34 | * `public function beforeStartingDeploy()` 35 | * `public function beforeFinishingDeploy()` 36 | * `public function beforeCancelingDeploy()` 37 | * `public function beforeStartingRollback()` 38 | * `public function beforeCancelingRollback()` 39 | * `public function beforeFinishingRollback()` 40 | 41 | Commands 42 | -------- 43 | 44 | There are no special commands for this deployer and there are no changes to the 45 | default commands defined for all deployers: 46 | 47 | * `runLocal(string $command)` 48 | * `runRemote(string $command)` 49 | * `log(string $message)` 50 | 51 | Skeleton 52 | -------- 53 | 54 | The following example shows the minimal code needed for this deployer. In 55 | addition to defining the configuration in `configure()`, you must implement 56 | three methods (`deploy()`, `cancelDeploy()`, `rollback()`) to define the logic 57 | of the deployment: 58 | 59 | ```php 60 | use EasyCorp\Bundle\EasyDeployBundle\Deployer\CustomDeployer; 61 | 62 | return new class extends CustomDeployer 63 | { 64 | public function configure() 65 | { 66 | return $this->getConfigBuilder() 67 | ->server('user@hostname') 68 | ; 69 | } 70 | 71 | public function deploy() 72 | { 73 | // ... 74 | } 75 | 76 | public function cancelDeploy() 77 | { 78 | // ... 79 | } 80 | 81 | public function rollback() 82 | { 83 | // ... 84 | } 85 | }; 86 | ``` 87 | 88 | Full Example 89 | ------------ 90 | 91 | The following example shows the full code needed to deploy a Symfony application 92 | to an Amazon AWS EC2 instance using `rsync`. The rollback feature is not 93 | implemented to not overcomplicate the example: 94 | 95 | ```php 96 | use EasyCorp\Bundle\EasyDeployBundle\Deployer\CustomDeployer; 97 | 98 | return new class extends CustomDeployer 99 | { 100 | private $deployDir = '/var/www/my-project'; 101 | 102 | public function configure() 103 | { 104 | return $this->getConfigBuilder() 105 | ->server('user@ec2-123-123-123-123.us-west-1.compute.amazonaws.com') 106 | ; 107 | } 108 | 109 | public function beforeStartingDeploy() 110 | { 111 | $this->log('Checking that the repository is in a clean state.'); 112 | $this->runLocal('git diff --quiet'); 113 | 114 | $this->log('Preparing the app'); 115 | $this->runLocal('rm -fr ./var/cache/*'); 116 | $this->runLocal('SYMFONY_ENV=prod ./bin/console assets:install web/'); 117 | $this->runLocal('SYMFONY_ENV=prod ./bin/console lint:twig app/Resources/ --no-debug'); 118 | $this->runLocal('yarn install'); 119 | $this->runLocal('NODE_ENV=production ./node_modules/.bin/webpack --progress'); 120 | $this->runLocal('composer dump-autoload --optimize'); 121 | } 122 | 123 | public function deploy() 124 | { 125 | $server = $this->getServers()->findAll()[0]; 126 | 127 | $this->runRemote('cp app/Resources/views/maintenance.html web/maintenance.html'); 128 | $this->runLocal(sprintf('rsync --progress -crDpLt --force --delete ./ %s@%s:%s', $server->getUser(), $server->getHost(), $this->deployDir)); 129 | $this->runRemote('SYMFONY_ENV=prod sudo -u www-data bin/console cache:warmup --no-debug'); 130 | $this->runRemote('SYMFONY_ENV=prod sudo -u www-data bin/console app:update-contents --no-debug'); 131 | $this->runRemote('rm -rf web/maintenance.html'); 132 | 133 | $this->runRemote('sudo restart php7.1-fpm'); 134 | } 135 | 136 | public function cancelDeploy() 137 | { 138 | // ... 139 | } 140 | 141 | public function rollback() 142 | { 143 | // ... 144 | } 145 | }; 146 | ``` 147 | -------------------------------------------------------------------------------- /doc/default-deployer.md: -------------------------------------------------------------------------------- 1 | Default Deployer 2 | ================ 3 | 4 | This is the deployment strategy used by default. It supports any number of 5 | remote servers and is a "rolling update", which deploys applications without any 6 | downtime. It's based on [Capistrano][1] and [Capifony][2]. If you know any of 7 | those, skip the first sections that explain what you need and how it works. 8 | 9 | What You Need 10 | ------------- 11 | 12 | * **Local Machine**: 13 | * A Symfony application with the EasyDeploy bundle installed. 14 | * A SSH client executable via the `ssh` console command. 15 | * **Remote Server/s**: 16 | * A SSH server that accepts connections from your local machine. 17 | * The Composer binary installed. 18 | * **Symfony Application**: 19 | * Code must be stored in a Git server (GitHub, BitBucket, GitLab, your own 20 | server) accessible from the local machine. 21 | * The application can use any Symfony version (2.7+, 3.x, 4.x). 22 | 23 | How Does It Work 24 | ---------------- 25 | 26 | The deployer creates a predefined directory structure on each remote server to 27 | store the application code and other data related to deployment. The root 28 | directory is defined by you with the `deployDir()` option: 29 | 30 | ```php 31 | public function configure() 32 | { 33 | return $this->getConfigBuilder() 34 | ->deployDir('/var/www/my-project') 35 | // ... 36 | ; 37 | } 38 | ``` 39 | 40 | Then, the following directory structure is created on each remote server: 41 | 42 | ``` 43 | /var/www/my-project/ 44 | ├── current -> /var/www/my-project/releases/20170517201708/ 45 | ├── releases 46 | │ ├── 20170517200103/ 47 | │ ├── 20170517200424/ 48 | │ ├── 20170517200736/ 49 | │ ├── 20170517201502/ 50 | │ └── 20170517201708/ 51 | ├── repo/ 52 | └── shared 53 | └── 54 | ``` 55 | 56 | * `current` is a symlink pointing to the most recent release. The trick of this 57 | deployment strategy is updating the symlink at the end of a successful deployment. 58 | If any error happens, the symlink can be reverted to the previous working version. 59 | That's how this strategy achieves the zero-downtime deployments. 60 | * `releases/` stores a configurable amount of past releases. The directory names 61 | are the timestamps of each release. 62 | * `repo/` stores a copy of the application's git repository and updates it for 63 | each deployment (this speeds up a lot the process of getting the application code). 64 | * `shared/` contains the files and directories configured as shared in your 65 | application (e.g. the "logs/" directory). These files/directories are shared 66 | between all releases. 67 | 68 | EasyDeploy creates this directory structure for you. There's no need to execute 69 | any command to setup the servers or configure anything. 70 | 71 | ### Web Server Configuration 72 | 73 | If you start using this strategy to deploy an already existing application, you 74 | may need to update your web server configuration. Specifically, you must update 75 | the document root to include the `current` symlink, which always points to the 76 | most recent version. The following example shows the changes needed for the 77 | Apache web server configuration: 78 | 79 | ```diff 80 | 81 | # ... 82 | 83 | - DocumentRoot /var/www/vhosts/example.com/web 84 | + DocumentRoot /var/www/vhosts/example.com/current/web 85 | DirectoryIndex app.php 86 | 87 | - 88 | + 89 | RewriteEngine On 90 | RewriteCond %{REQUEST_FILENAME} !-f 91 | RewriteRule ^(.*)$ app.php [QSA,L] 92 | 93 | 94 | # ... 95 | 96 | ``` 97 | 98 | Configuration 99 | ------------- 100 | 101 | Your IDE can autocomplete all the existing config options for this deployer, so 102 | you don't have to read this section or memorize any config option or special 103 | syntax. However, for reference purposes, all the config options are listed below: 104 | 105 | ### Common Options 106 | 107 | They are explained in the previous chapter about the configuration that is 108 | common for all deployers: 109 | 110 | * `->server(string $sshDsn, array $roles = ['app'], array $properties = [])` 111 | * `->useSshAgentForwarding(bool $useIt = true)` 112 | 113 | ### Composer and PHP Options 114 | 115 | * `->updateRemoteComposerBinary(bool $updateBeforeInstall = false)` 116 | * `->remoteComposerBinaryPath(string $path = '/usr/local/bin/composer')` 117 | * `->composerInstallFlags(string $flags = '--no-dev --prefer-dist --no-interaction --quiet')` 118 | * `->composerOptimizeFlags(string $flags = '--optimize --quiet')` 119 | * `->remotePhpBinaryPath(string $path = 'php')` the path of the PHP command 120 | added to Symfony commands. By default is `php` (which means: 121 | `php path/to/project/bin/console`). It's useful when the server has multiple 122 | PHP installations (e.g. `->remotePhpBinaryPath('/usr/bin/php7.1-sp')`) 123 | 124 | ### Code Options 125 | 126 | * `->repositoryUrl(string $url)` (it must be a Git repository) 127 | * `->repositoryBranch('master')` (the exact branch to deploy; usually `master` 128 | for `prod`, `staging` for the `staging` servers, etc.) 129 | * `->deployDir(string $path = '...')` (the directory in the remote server where 130 | the application is deployed) 131 | 132 | > **NOTE** 133 | > 134 | > Depending on your local and remote configuration, cloning the repository code 135 | > in the remote servers may fail. Read [this tutorial][4] to learn about the 136 | > most common ways to clone code on remote servers. 137 | 138 | ### Symfony Application Options 139 | 140 | The Symfony environment must be chosen carefully because, by default, commands 141 | are executed in that environment (`prod` by default): 142 | 143 | * `->symfonyEnvironment(string $name = 'prod')` 144 | 145 | The default value of these options depend on the Symfony version used by your 146 | application. Customize these options only if your application uses a directory 147 | structure different from the default one proposed by Symfony. The values are 148 | always relative to the project root dir: 149 | 150 | * `->consoleBinaryPath(string $path = '...')` (the dir where Symfony's `console` 151 | script is stored; e.g. `bin/`) 152 | * `->binDir(string $path = '...')` 153 | * `->configDir(string $path = '...')` 154 | * `->cacheDir(string $path = '...')` 155 | * `->logDir(string $path = '...')` 156 | * `->srcDir(string $path = '...')` 157 | * `->templatesDir(string $path = '...')` 158 | * `->webDir(string $path = '...')` 159 | 160 | This option configures the files and dirs which are shared between all releases. 161 | The values must be paths relative to the project root dir (which is usually 162 | `kernel.root_dir/../`). Its default value depends on the Symfony version used by 163 | your application: 164 | 165 | * `->sharedFilesAndDirs(array $paths = ['...'])` (by default, 166 | `app/config/parameters.yml` file in Symfony 2 and 3 and no file in Symfony 4; 167 | and the `app/logs/` dir in Symfony 2 and `var/logs/` in Symfony 3 and 4) 168 | 169 | These options enable/disable some operations commonly executed after the 170 | application is installed: 171 | 172 | * `->installWebAssets(bool $install = true)` 173 | * `->dumpAsseticAssets(bool $dump = false)` 174 | * `->warmupCache(bool $warmUp = true)` 175 | 176 | ### Security Options 177 | 178 | * `->controllersToRemove(array $paths = ['...'])` (values can be glob() expressions; 179 | by default is `app_*.php` in Symfony 2 and 3 and nothing in Symfony 4) 180 | * `->writableDirs(array $paths = ['...'])` (the dirs where the Symfony application 181 | can create files and dirs; by default, the cache/ and logs/ dirs) 182 | 183 | These options define the method used by EasyDeploy to set the permissions of the 184 | directories defined as "writable": 185 | 186 | * `->fixPermissionsWithChmod(string $mode = '0777')` 187 | * `->fixPermissionsWithChown(string $webServerUser)` 188 | * `->fixPermissionsWithChgrp(string $webServerGroup)` 189 | * `->fixPermissionsWithAcl(string $webServerUser)` 190 | 191 | ### Misc. Options 192 | 193 | * `->keepReleases(int $numReleases = 5)` (the number of past releases to keep 194 | when deploying a new version; if you want to roll back, this must be higher 195 | than `1`) 196 | * `->resetOpCacheFor(string $homepageUrl)` (if you use OPcache, you must reset 197 | it after each new deploy; however, you can't reset the OPcache contents from 198 | the command line; EasyDeploy uses a smart trick to reset the cache, but it 199 | needs to know the URL of the homepage of your application; e.g. `https://symfony.com`) 200 | 201 | Execution Flow 202 | -------------- 203 | 204 | In the previous chapters you learned about the "hooks", which are a way to 205 | execute your own commands before and after the deployment/rollback processes. 206 | The "hooks" which are common to all deployers are: 207 | 208 | * `public function beforeStartingDeploy()` 209 | * `public function beforeFinishingDeploy()` 210 | * `public function beforeCancelingDeploy()` 211 | * `public function beforeStartingRollback()` 212 | * `public function beforeCancelingRollback()` 213 | * `public function beforeFinishingRollback()` 214 | 215 | In addition to those, the default deployer adds the following hooks: 216 | 217 | * `public function beforeUpdating()`, executed just before the Git repository 218 | is updated for the branch defined above. 219 | * `public function beforePreparing()`, executed just before doing the 220 | `composer install`, setting the permissions, installing assets, etc. 221 | * `public function beforeOptimizing()`, executed just before clearing controllers, 222 | warming up the cache and optimizing Composer. 223 | * `public function beforePublishing()`, executed just before changing the 224 | symlink to the new release. 225 | * `public function beforeRollingBack()`, executed just before starting the 226 | roll back process. 227 | 228 | Commands 229 | -------- 230 | 231 | The `runLocal(string $command)` and `runRemote(string $command)` methods work as 232 | explained in the previous chapter. However, they are improved because you can 233 | use some variables inside them: 234 | 235 | ```php 236 | return new class extends DefaultDeployer 237 | { 238 | public function configure() 239 | { 240 | // ... 241 | } 242 | 243 | public function beforeStartingDeploy() 244 | { 245 | $this->runLocal('cp {{ templates_dir }}/maintenance.html.dist {{ web_dir }}/maintenance.html'); 246 | // equivalent to: 247 | // $this->runLocal('cp /path/to/project/app/Resources/views/maintenance.html.dist /path/to/project/web/maintenance.html'); 248 | } 249 | 250 | public function beforeFinishingDeploy() 251 | { 252 | $this->runRemote('{{ console_bin }} app:my-task-name'); 253 | // equivalent to: 254 | // $this->runRemote('php /path/to/project/bin/console app:my-task-name'); 255 | } 256 | } 257 | ``` 258 | 259 | These are the variables that can be used inside commands: 260 | 261 | * `{{ deploy_dir }}` (the same value that you configured earlier with `deployDir()`) 262 | * `{{ project_dir }}` (the exact directory of the current release, which is a 263 | timestamped directory inside `{{ deploy_dir }}/releases/`; when deploying to 264 | multiple servers, this directory is different on each of them, so you must 265 | always use this variable) 266 | * `{{ bin_dir }}` 267 | * `{{ config_dir }}` 268 | * `{{ cache_dir }}` 269 | * `{{ log_dir }}` 270 | * `{{ src_dir }}` 271 | * `{{ templates_dir }}` 272 | * `{{ web_dir }}` 273 | * `{{ console_bin }}` (the full Symfony `console` script executable; e.g. 274 | `php bin/console`) 275 | 276 | Skeleton 277 | -------- 278 | 279 | The following example shows the minimal code needed for this deployer. If your 280 | deployment is not heavily customized, there is no need to implement any other 281 | method besides `configure()`: 282 | 283 | ```php 284 | use EasyCorp\Bundle\EasyDeployBundle\Deployer\DefaultDeployer; 285 | 286 | return new class extends DefaultDeployer 287 | { 288 | public function configure() 289 | { 290 | return $this->getConfigBuilder() 291 | ->server('user@hostname') 292 | ->deployDir('/var/www/my-project') 293 | ->repositoryUrl('git@github.com:symfony/symfony-demo.git') 294 | ; 295 | } 296 | }; 297 | ``` 298 | 299 | Full Example 300 | ------------ 301 | 302 | The following example shows the full code needed to deploy the [Symfony Demo][3] 303 | application to two remote servers, execute some quality checks before deploying 304 | and post a message on a Slack channel when the deploy has finished: 305 | 306 | ```php 307 | use EasyCorp\Bundle\EasyDeployBundle\Deployer\DefaultDeployer; 308 | 309 | return new class extends DefaultDeployer 310 | { 311 | public function configure() 312 | { 313 | return $this->getConfigBuilder() 314 | ->server('deployer@123.123.123.123') 315 | ->server('deployer@host2.example.com') 316 | ->deployDir('/var/www/symfony-demo') 317 | ->repositoryUrl('git@github.com:symfony/symfony-demo.git') 318 | ->symfonyEnvironment('prod') 319 | ->resetOpCacheFor('https://demo.symfony.com') 320 | ; 321 | } 322 | 323 | public function beforeStartingDeploy() 324 | { 325 | $this->log('Checking that the repository is in a clean state.'); 326 | $this->runLocal('git diff --quiet'); 327 | 328 | $this->log('Running tests, linters and checkers.'); 329 | $this->runLocal('./bin/console security:check --env=dev'); 330 | $this->runLocal('./bin/console lint:twig app/Resources/ --no-debug'); 331 | $this->runLocal('./bin/console lint:yaml app/ --no-debug'); 332 | $this->runLocal('./bin/console lint:xliff app/Resources/ --no-debug'); 333 | $this->runLocal('./vendor/bin/simple-phpunit'); 334 | } 335 | 336 | public function beforeFinishingDeploy() 337 | { 338 | $slackHook = 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'; 339 | $message = json_encode(['text' => 'Application successfully deployed!']); 340 | $this->runLocal(sprintf("curl -X POST -H 'Content-type: application/json' --data '%s' %s", $message, $slackHook)); 341 | } 342 | }; 343 | ``` 344 | 345 | [1]: http://capistranorb.com/ 346 | [2]: https://github.com/everzet/capifony 347 | [3]: https://github.com/symfony/symfony-demo 348 | [4]: tutorials/remote-code-cloning.md 349 | -------------------------------------------------------------------------------- /doc/getting-started.md: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | Deploying and Rolling Back 5 | -------------------------- 6 | 7 | After installing the bundle, your Symfony application will have two new global 8 | commands called ``deploy`` and ``rollback``. The ``deploy`` command publishes 9 | your local Symfony application into one or more remote servers. The ``rollback`` 10 | command reverts the remote Symfony application to the previous version. 11 | 12 | EasyDeploy can deploy to any number of servers, even when they are of different 13 | type (e.g. two web servers, one database server and one worker server). It also 14 | supports multiple stages, so you can tailor the deployed application to 15 | different needs (production servers, staging server, etc.) 16 | 17 | Each stage uses its own configuration file. The default stage is called `prod`, 18 | but you can pass any stage name as the argument of the deploy/rollback commands: 19 | 20 | ```bash 21 | # deploy the current application to the "prod" server(s) 22 | $ ./bin/console deploy 23 | 24 | # deploy the current application to the "staging" server(s) 25 | $ ./bin/console deploy staging 26 | 27 | # rolls back the app in "prod" server(s) to its previous version 28 | $ ./bin/console rollback 29 | 30 | # rolls back the app in "qa" server(s) to its previous version 31 | $ ./bin/console rollback qa 32 | ``` 33 | 34 | Debugging Issues 35 | ---------------- 36 | 37 | A single failure in a single command in any server cancels the entire deployment 38 | process automatically. This is done to avoid leaving you with a half-deployed 39 | application. 40 | 41 | The full details of the deployment process, including the commands executed on 42 | remote servers and their results, are logged in a file named after the stage. 43 | For example, if you deploy a Symfony 3 application to the `prod` stage, the log 44 | file will be `var/logs/deploy_prod.log`. 45 | 46 | If you prefer to see the detailed information in real time, add the `-v` option 47 | to the deploy/rollback commands to run them in verbose mode: 48 | 49 | ```bash 50 | # '-v' shows the full details of the deploy/roll back processes 51 | $ ./bin/console deploy -v 52 | $ ./bin/console rollback -v 53 | ``` 54 | 55 | > **TIP** 56 | > 57 | > There are lots of reasons why SSH connections to remote servers may fail. Check 58 | > out this [list of common SSH connection issues][1] and their possible solutions. 59 | 60 | Testing the Deployment and the Rollback 61 | --------------------------------------- 62 | 63 | Testing a new deployment tool is always scary. Will it work as promised? Will it 64 | fail and wipe out my servers? For that reason, EasyDeploy commands include a 65 | `--dry-run` option to **show the commands executed by the deployment/rollback, 66 | without actually executing them**. Always include this option when using 67 | EasyDeploy for the first time: 68 | 69 | ```bash 70 | # show the commands to deploy into "prod", but don't execute them 71 | $ ./bin/console deploy --dry-run 72 | 73 | # show the commands to roll back "staging" server(s), but don't execute them 74 | $ ./bin/console rollback staging --dry-run 75 | ``` 76 | 77 | This option is more interesting when combined with the `-v` option, so you can 78 | see in real-time the full details of the executed commands without actually 79 | executing any of them. 80 | 81 | You are almost ready to deploy your Symfony application. Read the next article 82 | so you can learn how to configure the deployment in less than one minute. 83 | 84 | [1]: tutorials/remote-ssh-config.md 85 | -------------------------------------------------------------------------------- /doc/installation.md: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | EasyDeploy is distributed as a bundle that must be installed in each Symfony 5 | application that you want to deploy. 6 | 7 | If you use Symfony Flex 8 | ----------------------- 9 | 10 | ```console 11 | $ cd your-symfony-project/ 12 | $ composer require --dev easycorp/easy-deploy-bundle 13 | ``` 14 | 15 | And that's it! You can skip the rest of this article. 16 | 17 | If you don't use Symfony Flex 18 | ----------------------------- 19 | 20 | **Step 1.** Download the bundle: 21 | 22 | ```console 23 | $ cd your-symfony-project/ 24 | $ composer require --dev easycorp/easy-deploy-bundle 25 | ``` 26 | 27 | **Step 2.** Enable the bundle: 28 | 29 | ```php 30 | // ... 31 | class AppKernel extends Kernel 32 | { 33 | public function registerBundles() 34 | { 35 | // ... 36 | 37 | if (in_array($this->getEnvironment(), ['dev', 'test'], true)) { 38 | // ... 39 | $bundles[] = new EasyCorp\Bundle\EasyDeployBundle\EasyDeployBundle(); 40 | } 41 | 42 | return $bundles; 43 | } 44 | 45 | // ... 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /doc/tutorials/local-ssh-config.md: -------------------------------------------------------------------------------- 1 | Creating a Local SSH Configuration File 2 | ======================================= 3 | 4 | If you connect to lots of different servers using SSH, you may end up executing 5 | complex commands like the following: 6 | 7 | ```bash 8 | $ ssh user1@123.123.123.123 -p 22123 9 | $ ssh deployer@host23.example.com 10 | $ ssh client2@staging.example.com -p 22001 11 | ``` 12 | 13 | Remembering the user, hostname or IP and port number for each SSH connection is 14 | not easy. Luckily, SSH lets you store this information in a configuration file 15 | so you can connect to those servers using easy-to-remember names: 16 | 17 | ```bash 18 | $ ssh client1 19 | $ ssh client2 20 | $ ssh client2-staging 21 | ``` 22 | 23 | Defining the SSH Server Configuration 24 | ------------------------------------- 25 | 26 | Let's consider that you execute the `ssh user1@123.123.123.123 -p 22123` command 27 | to connect to the server of your client called `client1`. 28 | 29 | **Step 1.** Edit (or create if it doesn't exist) a file called `~/.ssh/config` 30 | (this is a file called `config` in a hidden directory called `.ssh/` inside your 31 | user directory; for example `/Users/jane`). 32 | 33 | **Step 2.** Add the configuration for the server using this format: 34 | 35 | ```ini 36 | Host client1 37 | HostName 123.123.123.123 38 | User user1 39 | Port 22123 40 | ``` 41 | 42 | **Step 3.** Save the changes, close the `~/.ssh/config` file and test the new 43 | config executing the following command: `ssh client1`. You should be connected 44 | to the server of your client. 45 | 46 | **Step 4.** Now repeat the above steps to add the config of the rest of the 47 | servers. 48 | 49 | Additional Config Options 50 | ------------------------- 51 | 52 | The above example used the `HostName`, `User` and `Port` options, but SSH config 53 | files can define a lot of other options. These are the most common: 54 | 55 | ```ini 56 | Host client1 57 | # ... 58 | 59 | # if set to 'yes', SSH compresses any communication between your local computer 60 | # and the remote server. At first it looks like a good idea, but it should be 61 | # enabled only for slow connections. On fast connections, this setting will 62 | # actually make your connection slower because of the compression overhead. 63 | Compression yes 64 | 65 | # some people don't recommend using this option because it may be dangerous 66 | # in some scenarios. In any case, there's no need to define it in this config 67 | # file because you can enable/disable it using the deployer config file. 68 | ForwardAgent yes 69 | 70 | # it defines the timeout interval (in seconds) after which, if no data has been 71 | # received from the server, ssh will send a message to the server to maintain the 72 | # connection alive (it's useful to avoid connection drops because of inactivity) 73 | ServerAliveInterval 60 74 | 75 | # it defines the path to the private key used to connect to the server. By 76 | # default it uses one of these files: ~/.ssh/{id_dsa,id_ecdsa,id_rsa} 77 | # Define this option only for advanced scenarios where you use different auth 78 | # keys per remote server. 79 | IdentityFile /path/to/ssh_id_rsa or /path/to/ssh_id_dsa or /path/to/ssh_id_ecdsa 80 | ``` 81 | 82 | See the [full list of SSH configuration options][1]. 83 | 84 | [1]: http://man.openbsd.org/ssh_config 85 | -------------------------------------------------------------------------------- /doc/tutorials/remote-code-cloning.md: -------------------------------------------------------------------------------- 1 | Cloning the Application Code on Remote Servers 2 | ============================================== 3 | 4 | Some deployment strategies clone on the remote servers the application code 5 | hosted on an external repository (e.g. GitHub). Depending on your local and 6 | remote configuration, this process may fail. 7 | 8 | This article explains the most common solutions for those problems. The examples 9 | below use GitHub.com, but you can translate those ideas to other Git services, 10 | such as BitBucket and GitLab. 11 | 12 | SSH Agent Forwarding 13 | -------------------- 14 | 15 | **Agent forwarding** is the strategy recommended by this bundle and used by 16 | default. If enabled, remote servers can use your local SSH keys to access other 17 | external services. First, execute this command in your local machine, to verify 18 | that you can access to GitHub web site using SSH: 19 | 20 | ```bash 21 | $ ssh -T git@github.com 22 | 23 | Hi ! You've successfully authenticated, but GitHub does not 24 | provide shell access. 25 | ``` 26 | 27 | Now log in any of your remote servers and execute the same command: 28 | 29 | * If you see the same output, agent forwarding is working and you can use it 30 | to deploy the applications. This option is enabled by default and can be 31 | changed with the `useSshAgentForwarding` option in your deployer. 32 | * If you encounter the error **Permission denied (publickey)**, check that: 33 | * The local SSH config file has not disabled agent forwarding for that host 34 | (see [this tutorial][1] for more details). 35 | * The remote SSH server hasn't disabled agent forwarding (see [this tutorial][2] 36 | for more details). 37 | * Read [this GitHub guide][3] to troubleshoot agent forwarding issues. 38 | 39 | Deploy Keys 40 | ----------- 41 | 42 | If you can't or don't want to use SSH agent forwarding, the other simple way to 43 | clone the code on remote servers is using **deploy keys**. They are SSH keys 44 | stored on your remote servers and they grant access to a single GitHub 45 | repository. The key is attached to a given repository instead of to a personal 46 | user account. Follow these steps: 47 | 48 | 1. Log in into one of your remote servers. 49 | 2. Execute this command to generate a new key: 50 | 51 | ```bash 52 | $ ssh-keygen -t rsa -b 4096 -C "your_email@example.com" 53 | ``` 54 | 55 | Press `` for all questions asked to use the default answers. This 56 | makes your key to not have a "passphrase", but don't worry because your 57 | key will still be safe. 58 | 3. The command generates two files, one of the private key and the other one 59 | for the public key. The deploy key is the public key. Display its contents 60 | so you can copy them. For example: `cat ~/.ssh/id_rsa.pub` (you'll see some 61 | random characters that start with `ssh-rsa` and end with your own email). 62 | 4. Go to the page of your code repository on GitHub and click on the **Settings** 63 | option (the URL is `https://github.com///settings`). 64 | 5. Click on the **Deploy keys** option on the sidebar menu. 65 | 6. Click on the **Add deploy key** button, give it a name (e.g. `server-1`) 66 | and paste the contents of the public key that you copied earlier. 67 | 7. Click on the **Add key** button to add the key and your remote server will 68 | now be able to clone that specific repository. 69 | 8. If the same server needs access to other repositories, repeat the process 70 | to add the same public key in other repositories. 71 | 9. If other servers need access to this repository, repeat the process to 72 | generate keys on those servers and add them in this repository. 73 | 74 | Read this guide if you any problem generating the SSH keys: 75 | [Connecting to GitHub with SSH][4]. 76 | 77 | Other Cloning Techniques 78 | ------------------------ 79 | 80 | If you can't or don't want to use SSH Agent Forwarding or Deploy Keys, you can 81 | use HTTPS Oauth Tokens and even create Machine Users and add them as 82 | collaborators in your GitHub repositories. Read [this guide][5] to learn more 83 | about those techniques. 84 | 85 | [1]: local-ssh-config.md 86 | [2]: remote-ssh-config.md 87 | [3]: https://developer.github.com/v3/guides/using-ssh-agent-forwarding/ 88 | [4]: https://help.github.com/articles/connecting-to-github-with-ssh/ 89 | [5]: https://developer.github.com/v3/guides/managing-deploy-keys/ 90 | -------------------------------------------------------------------------------- /doc/tutorials/remote-ssh-config.md: -------------------------------------------------------------------------------- 1 | Troubleshooting Connection Issues to Remote SSH Servers 2 | ======================================================= 3 | 4 | EasyDeploy requires working SSH connections to remote servers in order to deploy 5 | and roll back the applications. This article summarizes the most common SSH 6 | issues and proposes some solutions. 7 | 8 | You can also add the `-v` option to the `ssh` command to enable its verbose mode 9 | and print debugging messages to help you find connection, authentication, and 10 | configuration problems (e.g. `ssh -v my-server`). 11 | 12 | Connection Refused 13 | ------------------ 14 | 15 | ### There may be too many simultaneous SSH connections to the server 16 | 17 | Check the `MaxSessions` and `MaxStartups` config options of the SSH server. 18 | 19 | ```ini 20 | # /etc/ssh/sshd_config 21 | # ... 22 | 23 | # Defines the maximum number of concurrent unauthenticated connections. Additional 24 | # connections are dropped until authentication succeeds. Default: 10:30:100 25 | # (if there are '10' connections, refuse '30'% of them and increase the drop rate 26 | # linearly until '100' connections are reached and then all are refused) 27 | MaxStartups 10:30:100 28 | 29 | # Defines the maximum number of open shell, login or subsystem (e.g. sftp) 30 | # sessions permitted per connection. This mostly affects users with 31 | # multiplexing connections. 32 | MaxSessions 10 33 | ``` 34 | 35 | ### The firewall of the remote server may be dropping your SSH connections 36 | 37 | Deploying an application requires making lots of SSH connections in a short 38 | period of time. Some firewalls may consider that a suspicious behavior and start 39 | dropping some of your SSH connections. 40 | 41 | Permission denied 42 | ----------------- 43 | 44 | ### Remote servers may have disabled connections using public keys 45 | 46 | This is the recommended method to connect to remote SSH servers, but it may 47 | have been inadvertently disabled: 48 | 49 | ```ini 50 | # /etc/ssh/sshd_config 51 | # ... 52 | 53 | # set this option to 'yes' to enable authentication using public keys 54 | PubkeyAuthentication yes 55 | ``` 56 | 57 | ### Remote servers may have disabled connections using passwords 58 | 59 | Connecting to remote SSH servers with usernames and passwords is discouraged in 60 | favor of encryption keys. However, if you are still using passwords, make sure 61 | that servers allow to connect to them using passwords: 62 | 63 | ```ini 64 | # /etc/ssh/sshd_config 65 | # ... 66 | 67 | PasswordAuthentication yes 68 | ``` 69 | 70 | ### Remote servers may have disabled root login 71 | 72 | Login (and deploying) as the `root` user is a bad security practice. That's why 73 | most servers disable root logins. If you still want to connect as `root`, you 74 | must define the `PermitRootLogin` option: 75 | 76 | ```ini 77 | # /etc/ssh/sshd_config 78 | # ... 79 | 80 | # possible values are 'yes', 'no', 'prohibit-password', 'without-password' 81 | # and 'forced-commands-only' (default: 'prohibit-password') 82 | PermitRootLogin yes 83 | ``` 84 | 85 | ### Remote servers may have banned your user or group 86 | 87 | SSH servers can define a list of allowed/denied users and groups. Check that 88 | the user connecting to the server is allowed or at least not denied: 89 | 90 | ```ini 91 | # /etc/ssh/sshd_config 92 | # ... 93 | 94 | # these four options are processed in the following order and they accept both 95 | # full user/group names and patterns 96 | DenyUsers ... 97 | AllowUsers ... 98 | DenyGroups ... 99 | AllowGroups ... 100 | ``` 101 | 102 | Connection is Slow 103 | ------------------ 104 | 105 | ### DNS resolution may be enabled 106 | 107 | Disable the option that makes the remote SSH server to resolve host names. 108 | 109 | ```ini 110 | # /etc/ssh/sshd_config 111 | # ... 112 | 113 | # If set to 'yes', the SSH server looks up the remote host name to check that the 114 | # resolved host name for the remote IP address maps back to the very same IP address. 115 | UseDNS no 116 | ``` 117 | 118 | ### Compression may be disabled for the connection 119 | 120 | As explained [in this tutorial][1], the `Compression` option should be disabled 121 | for fast Internet connections, but it must be enabled for slow connections to 122 | improve the perceived performance significantly. 123 | 124 | Remote Servers Can't Clone the Repository Code 125 | ---------------------------------------------- 126 | 127 | ### ForwardAgent may be disabled by the remote server 128 | 129 | EasyDeploy enables by default the `ForwardAgent` option to let your remote 130 | servers clone the repository code from external sites such as GitHub. However, 131 | it's not enough to enable this option in your local SSH config file or in the 132 | `ssh` command used to connect to the server. 133 | 134 | Check that the `AllowAgentForwarding` option in the server is set to `yes`. 135 | 136 | ```ini 137 | # /etc/ssh/sshd_config 138 | # ... 139 | 140 | AllowAgentForwarding yes 141 | ``` 142 | 143 | [1]: local-ssh-config.md 144 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | tests/ 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Command/DeployCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Command; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Context; 15 | use EasyCorp\Bundle\EasyDeployBundle\Helper\SymfonyConfigPathGuesser; 16 | use Symfony\Component\Console\Command\Command; 17 | use Symfony\Component\Console\Input\InputArgument; 18 | use Symfony\Component\Console\Input\InputInterface; 19 | use Symfony\Component\Console\Input\InputOption; 20 | use Symfony\Component\Console\Output\OutputInterface; 21 | use Symfony\Component\Console\Question\ConfirmationQuestion; 22 | use Symfony\Component\Filesystem\Filesystem; 23 | use Symfony\Component\HttpKernel\Config\FileLocator; 24 | 25 | class DeployCommand extends Command 26 | { 27 | private $fileLocator; 28 | private $projectDir; 29 | private $logDir; 30 | private $configFilePath; 31 | 32 | public function __construct(FileLocator $fileLocator, string $projectDir, string $logDir) 33 | { 34 | $this->fileLocator = $fileLocator; 35 | $this->projectDir = realpath($projectDir); 36 | $this->logDir = $logDir; 37 | 38 | parent::__construct(); 39 | } 40 | 41 | protected function configure() 42 | { 43 | $this 44 | ->setName('deploy') 45 | ->setDescription('Deploys a Symfony application to one or more remote servers.') 46 | ->setHelp('...') 47 | ->addArgument('stage', InputArgument::OPTIONAL, 'The stage to deploy to ("production", "staging", etc.)', 'prod') 48 | ->addOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'Load configuration from the given file path') 49 | ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Shows the commands to perform the deployment without actually executing them') 50 | ; 51 | } 52 | 53 | protected function initialize(InputInterface $input, OutputInterface $output) 54 | { 55 | $customConfigPath = $input->getOption('configuration'); 56 | if (null !== $customConfigPath && !is_readable($customConfigPath)) { 57 | throw new \RuntimeException(sprintf("The given configuration file ('%s') does not exist or it's not readable.", $customConfigPath)); 58 | } 59 | 60 | if (null !== $customConfigPath && is_readable($customConfigPath)) { 61 | return $this->configFilePath = $customConfigPath; 62 | } 63 | 64 | $defaultConfigPath = SymfonyConfigPathGuesser::guess($this->projectDir, $input->getArgument('stage')); 65 | if (is_readable($defaultConfigPath)) { 66 | return $this->configFilePath = $defaultConfigPath; 67 | } 68 | 69 | $this->createDefaultConfigFile($input, $output, $defaultConfigPath, $input->getArgument('stage')); 70 | } 71 | 72 | protected function execute(InputInterface $input, OutputInterface $output) 73 | { 74 | $logFilePath = sprintf('%s/deploy_%s.log', $this->logDir, $input->getArgument('stage')); 75 | $context = new Context($input, $output, $this->projectDir, $logFilePath, true === $input->getOption('dry-run'), $output->isVerbose()); 76 | 77 | $deployer = include $this->configFilePath; 78 | $deployer->initialize($context); 79 | $deployer->doDeploy(); 80 | 81 | return 0; 82 | } 83 | 84 | private function createDefaultConfigFile(InputInterface $input, OutputInterface $output, string $defaultConfigPath, string $stageName): void 85 | { 86 | $helper = $this->getHelper('question'); 87 | $question = new ConfirmationQuestion(sprintf("\n WARNING There is no config file to deploy '%s' stage.\nDo you want to create a minimal config file for it? [Y/n] ", $stageName), true); 88 | 89 | if (!$helper->ask($input, $output, $question)) { 90 | $output->writeln(sprintf('OK, but before running this command again, create this config file: %s', $defaultConfigPath)); 91 | } else { 92 | (new Filesystem())->copy($this->fileLocator->locate('@EasyDeployBundle/Resources/skeleton/deploy.php.dist'), $defaultConfigPath); 93 | $output->writeln(sprintf('OK, now edit the "%s" config file and run this command again.', $defaultConfigPath)); 94 | } 95 | 96 | exit(0); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Command/RollbackCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Command; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Context; 15 | use EasyCorp\Bundle\EasyDeployBundle\Helper\SymfonyConfigPathGuesser; 16 | use Symfony\Component\Console\Command\Command; 17 | use Symfony\Component\Console\Input\InputArgument; 18 | use Symfony\Component\Console\Input\InputInterface; 19 | use Symfony\Component\Console\Input\InputOption; 20 | use Symfony\Component\Console\Output\OutputInterface; 21 | 22 | class RollbackCommand extends Command 23 | { 24 | private $projectDir; 25 | private $logDir; 26 | private $configFilePath; 27 | 28 | public function __construct(string $projectDir, string $logDir) 29 | { 30 | $this->projectDir = $projectDir; 31 | $this->logDir = $logDir; 32 | 33 | parent::__construct(); 34 | } 35 | 36 | protected function configure() 37 | { 38 | $this 39 | ->setName('rollback') 40 | ->setDescription('Deploys a Symfony application to one or more remote servers.') 41 | ->setHelp('...') 42 | ->addArgument('stage', InputArgument::OPTIONAL, 'The stage to roll back ("production", "staging", etc.)', 'prod') 43 | ->addOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'Load configuration from the given file path') 44 | ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Shows the commands to perform the roll back without actually executing them') 45 | ; 46 | } 47 | 48 | protected function initialize(InputInterface $input, OutputInterface $output) 49 | { 50 | $customConfigPath = $input->getOption('configuration'); 51 | if (null !== $customConfigPath && !is_readable($customConfigPath)) { 52 | throw new \RuntimeException(sprintf("The given configuration file ('%s') does not exist or it's not readable.", $customConfigPath)); 53 | } 54 | 55 | if (null !== $customConfigPath && is_readable($customConfigPath)) { 56 | return $this->configFilePath = $customConfigPath; 57 | } 58 | 59 | $defaultConfigPath = SymfonyConfigPathGuesser::guess($this->projectDir, $input->getArgument('stage')); 60 | if (is_readable($defaultConfigPath)) { 61 | return $this->configFilePath = $defaultConfigPath; 62 | } 63 | 64 | throw new \RuntimeException(sprintf("The default configuration file does not exist or it's not readable, and no custom configuration file was given either. Create the '%s' configuration file and run this command again.", $defaultConfigPath)); 65 | } 66 | 67 | protected function execute(InputInterface $input, OutputInterface $output) 68 | { 69 | $logFilePath = sprintf('%s/deploy_%s.log', $this->logDir, $input->getArgument('stage')); 70 | $context = new Context($input, $output, $this->projectDir, $logFilePath, true === $input->getOption('dry-run'), $output->isVerbose()); 71 | 72 | $deployer = include $this->configFilePath; 73 | $deployer->initialize($context); 74 | $deployer->doRollback(); 75 | 76 | return 0; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Configuration/AbstractConfiguration.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Configuration; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Exception\InvalidConfigurationException; 15 | use EasyCorp\Bundle\EasyDeployBundle\Server\Property; 16 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 17 | use EasyCorp\Bundle\EasyDeployBundle\Server\ServerRepository; 18 | 19 | /** 20 | * It implements the "Builder" pattern to define the configuration of the deployer. 21 | * This is the base builder extended by the specific builder used by each deployer. 22 | */ 23 | abstract class AbstractConfiguration 24 | { 25 | private const RESERVED_SERVER_PROPERTIES = [Property::use_ssh_agent_forwarding]; 26 | protected $servers; 27 | protected $useSshAgentForwarding = true; 28 | 29 | public function __construct() 30 | { 31 | $this->servers = new ServerRepository(); 32 | } 33 | 34 | public function server(string $sshDsn, array $roles = [Server::ROLE_APP], array $properties = []) 35 | { 36 | $reservedProperties = array_merge(self::RESERVED_SERVER_PROPERTIES, $this->getReservedServerProperties()); 37 | $reservedPropertiesUsed = array_intersect($reservedProperties, array_keys($properties)); 38 | if (!empty($reservedPropertiesUsed)) { 39 | throw new InvalidConfigurationException(sprintf('These properties set for the "%s" server are reserved: %s. Use different property names.', $sshDsn, implode(', ', $reservedPropertiesUsed))); 40 | } 41 | 42 | $this->servers->add(new Server($sshDsn, $roles, $properties)); 43 | } 44 | 45 | public function useSshAgentForwarding(bool $useIt) 46 | { 47 | $this->useSshAgentForwarding = $useIt; 48 | } 49 | 50 | abstract protected function getReservedServerProperties(): array; 51 | } 52 | -------------------------------------------------------------------------------- /src/Configuration/ConfigurationAdapter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Configuration; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Helper\Str; 15 | use Symfony\Component\HttpFoundation\ParameterBag; 16 | 17 | /** 18 | * It implements the "Adapter" pattern to allow working with the configuration 19 | * in a consistent manner, even if the configuration of each deployer is 20 | * completely different and defined using incompatible objects. 21 | */ 22 | final class ConfigurationAdapter 23 | { 24 | private $config; 25 | /** @var ParameterBag */ 26 | private $options; 27 | 28 | public function __construct(AbstractConfiguration $config) 29 | { 30 | $this->config = $config; 31 | } 32 | 33 | public function __toString(): string 34 | { 35 | return Str::formatAsTable($this->getOptions()->all()); 36 | } 37 | 38 | public function get(string $optionName) 39 | { 40 | if (!$this->getOptions()->has($optionName)) { 41 | throw new \InvalidArgumentException(sprintf('The "%s" option is not defined.', $optionName)); 42 | } 43 | 44 | return $this->getOptions()->get($optionName); 45 | } 46 | 47 | private function getOptions(): ParameterBag 48 | { 49 | if (null !== $this->options) { 50 | return $this->options; 51 | } 52 | 53 | // it's not the most beautiful code possible, but making the properties 54 | // private and the methods public allows to configure the deployment using 55 | // a config builder and the IDE autocompletion. Here we need to access 56 | // those private properties and their values 57 | $options = new ParameterBag(); 58 | $r = new \ReflectionObject($this->config); 59 | foreach ($r->getProperties() as $property) { 60 | try { 61 | $property->setAccessible(true); 62 | $options->set($property->getName(), $property->getValue($this->config)); 63 | } catch (\ReflectionException $e) { 64 | // ignore this error 65 | } 66 | } 67 | 68 | return $this->options = $options; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Configuration/CustomConfiguration.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Configuration; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 15 | 16 | /** 17 | * It implements the "Builder" pattern to define the configuration of the custom 18 | * deployer using a fluent interface and enabling the IDE autocompletion. 19 | */ 20 | class CustomConfiguration extends AbstractConfiguration 21 | { 22 | // this proxy method is needed because the autocompletion breaks 23 | // if the parent method is used directly 24 | public function server(string $sshDsn, array $roles = [Server::ROLE_APP], array $properties = []): self 25 | { 26 | parent::server($sshDsn, $roles, $properties); 27 | 28 | return $this; 29 | } 30 | 31 | // this proxy method is needed because the autocompletion breaks 32 | // if the parent method is used directly 33 | public function useSshAgentForwarding(bool $useIt): self 34 | { 35 | parent::useSshAgentForwarding($useIt); 36 | 37 | return $this; 38 | } 39 | 40 | protected function getReservedServerProperties(): array 41 | { 42 | return []; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Configuration/DefaultConfiguration.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Configuration; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Exception\InvalidConfigurationException; 15 | use EasyCorp\Bundle\EasyDeployBundle\Helper\Str; 16 | use EasyCorp\Bundle\EasyDeployBundle\Server\Property; 17 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 18 | use Symfony\Component\HttpKernel\Kernel; 19 | 20 | /** 21 | * It implements the "Builder" pattern to define the configuration of the 22 | * default deployer using a fluent interface and enabling the IDE autocompletion. 23 | */ 24 | final class DefaultConfiguration extends AbstractConfiguration 25 | { 26 | // variables starting with an underscore are for internal use only 27 | private $_symfonyEnvironmentEnvVarName; // SYMFONY_ENV or APP_ENV 28 | 29 | // properties are defined as private so the developer doesn't see them when using 30 | // their IDE autocompletion. To simplify things, the builder defines setter 31 | // methods named the same as each option. 32 | private $symfonyEnvironment = 'prod'; 33 | private $keepReleases = 5; 34 | private $repositoryUrl; 35 | private $repositoryBranch = 'master'; 36 | private $remotePhpBinaryPath = 'php'; 37 | private $updateRemoteComposerBinary = false; 38 | private $remoteComposerBinaryPath = '/usr/local/bin/composer'; 39 | private $composerInstallFlags = '--no-dev --prefer-dist --no-interaction --quiet'; 40 | private $composerOptimizeFlags = '--optimize'; 41 | private $installWebAssets = true; 42 | private $dumpAsseticAssets = false; 43 | private $warmupCache = true; 44 | private $consoleBinaryPath; 45 | private $localProjectDir; 46 | private $binDir; 47 | private $configDir; 48 | private $cacheDir; 49 | private $deployDir; 50 | private $logDir; 51 | private $srcDir; 52 | private $templatesDir; 53 | private $webDir; 54 | private $controllersToRemove = []; 55 | private $writableDirs = []; 56 | private $permissionMethod = 'chmod'; 57 | private $permissionMode = '0777'; 58 | private $permissionUser; 59 | private $permissionGroup; 60 | private $sharedFiles = []; 61 | private $sharedDirs = []; 62 | private $resetOpCacheFor; 63 | 64 | public function __construct(string $localProjectDir) 65 | { 66 | parent::__construct(); 67 | $this->localProjectDir = $localProjectDir; 68 | $this->setDefaultConfiguration(Kernel::MAJOR_VERSION, Kernel::MINOR_VERSION); 69 | } 70 | 71 | // this proxy method is needed because the autocompletion breaks 72 | // if the parent method is used directly 73 | public function server(string $sshDsn, array $roles = [Server::ROLE_APP], array $properties = []): self 74 | { 75 | parent::server($sshDsn, $roles, $properties); 76 | 77 | return $this; 78 | } 79 | 80 | // this proxy method is needed because the autocompletion breaks 81 | // if the parent method is used directly 82 | public function useSshAgentForwarding(bool $useIt): self 83 | { 84 | parent::useSshAgentForwarding($useIt); 85 | 86 | return $this; 87 | } 88 | 89 | public function symfonyEnvironment(string $name): self 90 | { 91 | $this->symfonyEnvironment = $name; 92 | 93 | return $this; 94 | } 95 | 96 | public function keepReleases(int $numReleases): self 97 | { 98 | $this->keepReleases = $numReleases; 99 | 100 | return $this; 101 | } 102 | 103 | public function repositoryUrl(string $url): self 104 | { 105 | // SSH agent forwarding only works when using SSH URLs, not https URLs. Check these URLs: 106 | // https://github.com// 107 | // https://bitbucket.org// 108 | // https://gitlab.com//.git 109 | if (Str::startsWith($url, 'http://') || Str::startsWith($url, 'https://')) { 110 | $sshUrl = preg_replace('/https?:\/\/(?.*)\/(?.*)\/(?.*)/', 'git@$1:$2/$3', $url); 111 | if (!Str::endsWith($sshUrl, '.git')) { 112 | $sshUrl .= '.git'; 113 | } 114 | 115 | throw new InvalidConfigurationException(sprintf('The repository URL must use the SSH syntax instead of the HTTPs syntax to make it work on any remote server. Replace "%s" by "%s"', $url, $sshUrl)); 116 | } 117 | 118 | $this->repositoryUrl = $url; 119 | 120 | return $this; 121 | } 122 | 123 | public function repositoryBranch(string $branchName): self 124 | { 125 | $this->repositoryBranch = $branchName; 126 | 127 | return $this; 128 | } 129 | 130 | public function remotePhpBinaryPath(string $path): self 131 | { 132 | $this->remotePhpBinaryPath = $path; 133 | 134 | return $this; 135 | } 136 | 137 | public function updateRemoteComposerBinary(bool $updateBeforeInstall): self 138 | { 139 | $this->updateRemoteComposerBinary = $updateBeforeInstall; 140 | 141 | return $this; 142 | } 143 | 144 | public function remoteComposerBinaryPath(string $path): self 145 | { 146 | $this->remoteComposerBinaryPath = $path; 147 | 148 | return $this; 149 | } 150 | 151 | public function composerInstallFlags(string $flags): self 152 | { 153 | $this->composerInstallFlags = $flags; 154 | 155 | return $this; 156 | } 157 | 158 | public function composerOptimizeFlags(string $flags): self 159 | { 160 | $this->composerOptimizeFlags = $flags; 161 | 162 | return $this; 163 | } 164 | 165 | public function installWebAssets(bool $install): self 166 | { 167 | $this->installWebAssets = $install; 168 | 169 | return $this; 170 | } 171 | 172 | public function dumpAsseticAssets(bool $dump): self 173 | { 174 | $this->dumpAsseticAssets = $dump; 175 | 176 | return $this; 177 | } 178 | 179 | public function warmupCache(bool $warmUp): self 180 | { 181 | $this->warmupCache = $warmUp; 182 | 183 | return $this; 184 | } 185 | 186 | public function consoleBinaryPath(string $path): self 187 | { 188 | $this->consoleBinaryPath = $path; 189 | 190 | return $this; 191 | } 192 | 193 | // Relative to the project root directory 194 | public function binDir(string $path): self 195 | { 196 | $this->validatePathIsRelativeToProject($path, __METHOD__); 197 | $this->binDir = rtrim($path, '/'); 198 | 199 | return $this; 200 | } 201 | 202 | // Relative to the project root directory 203 | public function configDir(string $path): self 204 | { 205 | $this->validatePathIsRelativeToProject($path, __METHOD__); 206 | $this->configDir = rtrim($path, '/'); 207 | 208 | return $this; 209 | } 210 | 211 | // Relative to the project root directory 212 | public function cacheDir(string $path): self 213 | { 214 | $this->validatePathIsRelativeToProject($path, __METHOD__); 215 | $this->cacheDir = rtrim($path, '/'); 216 | 217 | return $this; 218 | } 219 | 220 | public function deployDir(string $path): self 221 | { 222 | $this->deployDir = rtrim($path, '/'); 223 | 224 | return $this; 225 | } 226 | 227 | // Relative to the project root directory 228 | public function logDir(string $path): self 229 | { 230 | $this->validatePathIsRelativeToProject($path, __METHOD__); 231 | $this->logDir = rtrim($path, '/'); 232 | 233 | return $this; 234 | } 235 | 236 | // Relative to the project root directory 237 | public function srcDir(string $path): self 238 | { 239 | $this->validatePathIsRelativeToProject($path, __METHOD__); 240 | $this->srcDir = rtrim($path, '/'); 241 | 242 | return $this; 243 | } 244 | 245 | // Relative to the project root directory 246 | public function templatesDir(string $path): self 247 | { 248 | $this->validatePathIsRelativeToProject($path, __METHOD__); 249 | $this->templatesDir = rtrim($path, '/'); 250 | 251 | return $this; 252 | } 253 | 254 | // Relative to the project root directory 255 | public function webDir(string $path): self 256 | { 257 | $this->validatePathIsRelativeToProject($path, __METHOD__); 258 | $this->webDir = rtrim($path, '/'); 259 | 260 | return $this; 261 | } 262 | 263 | // Relative to the project root directory 264 | // the $paths can be glob() patterns, so this method needs to resolve them 265 | public function controllersToRemove(array $paths): self 266 | { 267 | $absoluteGlobPaths = array_map(function ($globPath) { 268 | return $this->localProjectDir.DIRECTORY_SEPARATOR.$globPath; 269 | }, $paths); 270 | 271 | $localAbsolutePaths = []; 272 | foreach ($absoluteGlobPaths as $path) { 273 | $localAbsolutePaths = array_merge($localAbsolutePaths, glob($path)); 274 | } 275 | 276 | $localRelativePaths = array_map(function ($absolutePath) { 277 | $relativePath = str_replace($this->localProjectDir, '', $absolutePath); 278 | $this->validatePathIsRelativeToProject($relativePath, 'controllersToRemove'); 279 | 280 | return trim($relativePath, DIRECTORY_SEPARATOR); 281 | }, $localAbsolutePaths); 282 | 283 | $this->controllersToRemove = $localRelativePaths; 284 | 285 | return $this; 286 | } 287 | 288 | // Relative to the project root directory 289 | public function writableDirs(array $paths): self 290 | { 291 | foreach ($paths as $path) { 292 | $this->validatePathIsRelativeToProject($path, __METHOD__); 293 | } 294 | $this->writableDirs = $paths; 295 | 296 | return $this; 297 | } 298 | 299 | public function fixPermissionsWithChmod(string $mode = '0777'): self 300 | { 301 | $this->permissionMethod = 'chmod'; 302 | $this->permissionMode = $mode; 303 | 304 | return $this; 305 | } 306 | 307 | public function fixPermissionsWithChown(string $webServerUser): self 308 | { 309 | $this->permissionMethod = 'chown'; 310 | $this->permissionUser = $webServerUser; 311 | 312 | return $this; 313 | } 314 | 315 | public function fixPermissionsWithChgrp(string $webServerGroup): self 316 | { 317 | $this->permissionMethod = 'chgrp'; 318 | $this->permissionGroup = $webServerGroup; 319 | 320 | return $this; 321 | } 322 | 323 | public function fixPermissionsWithAcl(string $webServerUser): self 324 | { 325 | $this->permissionMethod = 'acl'; 326 | $this->permissionUser = $webServerUser; 327 | 328 | return $this; 329 | } 330 | 331 | // Relative to the project root directory 332 | public function sharedFilesAndDirs(array $paths = []): self 333 | { 334 | $this->sharedDirs = []; 335 | $this->sharedFiles = []; 336 | 337 | foreach ($paths as $path) { 338 | $this->validatePathIsRelativeToProject($path, __METHOD__); 339 | if (is_dir($this->localProjectDir.DIRECTORY_SEPARATOR.$path)) { 340 | $this->sharedDirs[] = rtrim($path, DIRECTORY_SEPARATOR); 341 | } else { 342 | $this->sharedFiles[] = $path; 343 | } 344 | } 345 | 346 | return $this; 347 | } 348 | 349 | // the $homepageUrl (e.g. 'https://symfony.com') is needed because OPcache contents can't 350 | // be deleted from the terminal and deployer must make a HTTP request to a real website URL 351 | public function resetOpCacheFor(string $homepageUrl): self 352 | { 353 | if (!Str::startsWith($homepageUrl, 'http')) { 354 | throw new InvalidConfigurationException(sprintf('The value of %s option must be the valid URL of your homepage (it must start with http:// or https://).', Option::resetOpCacheFor)); 355 | } 356 | 357 | $this->resetOpCacheFor = rtrim($homepageUrl, '/'); 358 | 359 | return $this; 360 | } 361 | 362 | protected function getReservedServerProperties(): array 363 | { 364 | return [Property::bin_dir, Property::config_dir, Property::console_bin, Property::cache_dir, Property::deploy_dir, Property::log_dir, Property::src_dir, Property::templates_dir, Property::web_dir]; 365 | } 366 | 367 | private function setDefaultConfiguration(int $symfonyMajorVersion, $symfonyMinorVersion): void 368 | { 369 | if (2 === $symfonyMajorVersion) { 370 | $this->_symfonyEnvironmentEnvVarName = 'SYMFONY_ENV'; 371 | $this->setDirs('app', 'app/config', 'app/cache', 'app/logs', 'src', 'app/Resources/views', 'web'); 372 | $this->controllersToRemove(['web/app_*.php']); 373 | $this->sharedFiles = ['app/config/parameters.yml']; 374 | $this->sharedDirs = ['app/logs']; 375 | $this->writableDirs = ['app/cache/', 'app/logs/']; 376 | $this->dumpAsseticAssets = true; 377 | } elseif (3 === $symfonyMajorVersion && 4 < $symfonyMinorVersion) { 378 | $this->_symfonyEnvironmentEnvVarName = 'SYMFONY_ENV'; 379 | $this->setDirs('bin', 'app/config', 'var/cache', 'var/logs', 'src', 'app/Resources/views', 'web'); 380 | $this->controllersToRemove(['web/app_*.php']); 381 | $this->sharedFiles = ['app/config/parameters.yml']; 382 | $this->sharedDirs = ['var/logs']; 383 | $this->writableDirs = ['var/cache/', 'var/logs/']; 384 | } elseif (4 <= $symfonyMajorVersion || (3 === $symfonyMajorVersion && 4 >= $symfonyMinorVersion)) { 385 | $this->_symfonyEnvironmentEnvVarName = 'APP_ENV'; 386 | $this->setDirs('bin', 'config', 'var/cache', 'var/log', 'src', 'templates', 'public'); 387 | $this->controllersToRemove([]); 388 | $this->sharedDirs = ['var/log']; 389 | $this->writableDirs = ['var/cache/', 'var/log/']; 390 | } 391 | } 392 | 393 | private function setDirs(string $binDir, string $configDir, string $cacheDir, string $logDir, string $srcDir, string $templatesDir, string $webDir): void 394 | { 395 | $this->binDir = $binDir; 396 | $this->configDir = $configDir; 397 | $this->cacheDir = $cacheDir; 398 | $this->logDir = $logDir; 399 | $this->srcDir = $srcDir; 400 | $this->templatesDir = $templatesDir; 401 | $this->webDir = $webDir; 402 | } 403 | 404 | private function validatePathIsRelativeToProject($path, $methodName): void 405 | { 406 | if (!is_readable($this->localProjectDir.DIRECTORY_SEPARATOR.$path)) { 407 | throw new InvalidConfigurationException(sprintf('The "%s" value given in %s() is not relative to the project root directory or is not readable.', $path, $methodName)); 408 | } 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /src/Configuration/Option.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Configuration; 13 | 14 | /** 15 | * It defines the names of the configuration options for all deployers to avoid 16 | * using "magic strings" in the application. It's common to define PHP constants 17 | * in uppercase, but these are in lowercase because of how deployers are config. 18 | * Configuration uses autocompletion based on methods named like the options 19 | * (e.g. ->binDir() configures the $bindDir option). Using uppercase would 20 | * create ugly method names (e.g. ->BIN_DIR()). 21 | */ 22 | final class Option 23 | { 24 | const binDir = 'binDir'; 25 | const cacheDir = 'cacheDir'; 26 | const composerInstallFlags = 'composerInstallFlags'; 27 | const composerOptimizeFlags = 'composerOptimizeFlags'; 28 | const configDir = 'configDir'; 29 | const consoleBinaryPath = 'consoleBinaryPath'; 30 | const context = 'context'; 31 | const controllersToRemove = 'controllersToRemove'; 32 | const deployDir = 'deployDir'; 33 | const dumpAsseticAssets = 'dumpAsseticAssets'; 34 | const installWebAssets = 'installWebAssets'; 35 | const keepReleases = 'keepReleases'; 36 | const logDir = 'logDir'; 37 | const permissionMethod = 'permissionMethod'; 38 | const permissionMode = 'permissionMode'; 39 | const permissionUser = 'permissionUser'; 40 | const permissionGroup = 'permissionGroup'; 41 | const remotePhpBinaryPath = 'remotePhpBinaryPath'; 42 | const remoteComposerBinaryPath = 'remoteComposerBinaryPath'; 43 | const repositoryBranch = 'repositoryBranch'; 44 | const repositoryUrl = 'repositoryUrl'; 45 | const resetOpCacheFor = 'resetOpCacheFor'; 46 | const servers = 'servers'; 47 | const sharedFiles = 'sharedFiles'; 48 | const sharedDirs = 'sharedDirs'; 49 | const srcDir = 'srcDir'; 50 | const symfonyEnvironment = 'symfonyEnvironment'; 51 | const templatesDir = 'templatesDir'; 52 | const updateRemoteComposerBinary = 'updateRemoteComposerBinary'; 53 | const useSshAgentForwarding = 'useSshAgentForwarding'; 54 | const warmupCache = 'warmupCache'; 55 | const webDir = 'webDir'; 56 | const writableDirs = 'writableDirs'; 57 | } 58 | -------------------------------------------------------------------------------- /src/Context.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Server\Property; 15 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 16 | use Symfony\Component\Console\Input\InputInterface; 17 | use Symfony\Component\Console\Output\OutputInterface; 18 | 19 | /** 20 | * It implements the "Context Object" pattern to encapsulate the global state of 21 | * the deployment in an immutable object. 22 | */ 23 | class Context 24 | { 25 | private $localHost; 26 | private $dryRun; 27 | private $debug; 28 | private $input; 29 | private $output; 30 | private $projectDir; 31 | private $logFilePath; 32 | 33 | public function __construct(InputInterface $input, OutputInterface $output, string $projectDir, string $logFilePath, bool $isDryRun, bool $isVerbose) 34 | { 35 | $this->input = $input; 36 | $this->output = $output; 37 | $this->projectDir = $projectDir; 38 | $this->logFilePath = $logFilePath; 39 | $this->dryRun = $isDryRun; 40 | $this->debug = $isVerbose; 41 | 42 | $this->localHost = $this->createLocalHost(); 43 | } 44 | 45 | public function __toString(): string 46 | { 47 | return sprintf( 48 | 'dry-run = %s, debug = %s, logFile = %s, localHost = Object(Server), input = Object(InputInterface), output = Object(OutputInterface)', 49 | $this->dryRun ? 'true' : 'false', 50 | $this->debug ? 'true' : 'false', 51 | $this->logFilePath 52 | ); 53 | } 54 | 55 | public function getLocalHost(): Server 56 | { 57 | return $this->localHost; 58 | } 59 | 60 | public function getLogFilePath(): string 61 | { 62 | return $this->logFilePath; 63 | } 64 | 65 | public function getLocalProjectRootDir(): string 66 | { 67 | return $this->localHost->get(Property::project_dir); 68 | } 69 | 70 | public function isDryRun(): bool 71 | { 72 | return $this->dryRun; 73 | } 74 | 75 | public function isDebug(): bool 76 | { 77 | return $this->debug; 78 | } 79 | 80 | public function getInput(): InputInterface 81 | { 82 | return $this->input; 83 | } 84 | 85 | public function getOutput(): OutputInterface 86 | { 87 | return $this->output; 88 | } 89 | 90 | private function createLocalHost(): Server 91 | { 92 | $localhost = new Server('localhost'); 93 | $localhost->set(Property::project_dir, $this->projectDir); 94 | 95 | return $localhost; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/DependencyInjection/EasyDeployExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\DependencyInjection; 13 | 14 | use Symfony\Component\Config\FileLocator; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; 17 | use Symfony\Component\HttpKernel\DependencyInjection\Extension; 18 | 19 | class EasyDeployExtension extends Extension 20 | { 21 | public function load(array $configs, ContainerBuilder $container) 22 | { 23 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 24 | $loader->load('services.xml'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Deployer/AbstractDeployer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Deployer; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Configuration\ConfigurationAdapter; 15 | use EasyCorp\Bundle\EasyDeployBundle\Configuration\Option; 16 | use EasyCorp\Bundle\EasyDeployBundle\Context; 17 | use EasyCorp\Bundle\EasyDeployBundle\Helper\Str; 18 | use EasyCorp\Bundle\EasyDeployBundle\Logger; 19 | use EasyCorp\Bundle\EasyDeployBundle\Requirement\AbstractRequirement; 20 | use EasyCorp\Bundle\EasyDeployBundle\Server\Property; 21 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 22 | use EasyCorp\Bundle\EasyDeployBundle\Server\ServerRepository; 23 | use EasyCorp\Bundle\EasyDeployBundle\Task\Task; 24 | use EasyCorp\Bundle\EasyDeployBundle\Task\TaskCompleted; 25 | use EasyCorp\Bundle\EasyDeployBundle\Task\TaskRunner; 26 | 27 | abstract class AbstractDeployer 28 | { 29 | /** @var Context */ 30 | private $context; 31 | /** @var TaskRunner */ 32 | private $taskRunner; 33 | /** @var Logger */ 34 | private $logger; 35 | /** @var ConfigurationAdapter */ 36 | private $config; 37 | 38 | abstract public function getRequirements(): array; 39 | 40 | abstract public function deploy(); 41 | 42 | abstract public function cancelDeploy(); 43 | 44 | abstract public function rollback(); 45 | 46 | final public function getConfig(string $name) 47 | { 48 | return $this->config->get($name); 49 | } 50 | 51 | final public function doDeploy(): void 52 | { 53 | try { 54 | $this->log('Executing beforeStartingDeploy hook'); 55 | $this->beforeStartingDeploy(); 56 | $this->log('

Starting the deployment'); 57 | 58 | $this->deploy(); 59 | 60 | $this->log('Executing beforeFinishingDeploy hook'); 61 | $this->beforeFinishingDeploy(); 62 | $this->log('

Finishing the deployment'); 63 | } catch (\Exception $e) { 64 | $this->log('[ERROR] Cancelling the deployment and reverting the changes'); 65 | $this->log(sprintf('A log file with all the error details has been generated in %s', $this->context->getLogFilePath())); 66 | 67 | $this->log('Executing beforeCancelingDeploy hook'); 68 | $this->beforeCancelingDeploy(); 69 | $this->cancelDeploy(); 70 | 71 | throw $e; 72 | } 73 | 74 | $this->log(sprintf('[OK] Deployment was successful')); 75 | } 76 | 77 | final public function doRollback(): void 78 | { 79 | try { 80 | $this->log('Executing beforeStartingRollback hook'); 81 | $this->beforeStartingRollback(); 82 | $this->log('

Starting the rollback'); 83 | 84 | $this->rollback(); 85 | 86 | $this->log('Executing beforeFinishingRollback hook'); 87 | $this->beforeFinishingRollback(); 88 | $this->log('

Finishing the rollback'); 89 | } catch (\Exception $e) { 90 | $this->log('[ERROR] The roll back failed because of the following error'); 91 | $this->log(sprintf('A log file with all the error details has been generated in %s', $this->context->getLogFilePath())); 92 | 93 | $this->log('Executing beforeCancelingRollback hook'); 94 | $this->beforeCancelingRollback(); 95 | 96 | throw $e; 97 | } 98 | 99 | $this->log(sprintf('[OK] Rollback was successful')); 100 | } 101 | 102 | public function beforeStartingDeploy() 103 | { 104 | $this->log('

Nothing to execute'); 105 | } 106 | 107 | public function beforeCancelingDeploy() 108 | { 109 | $this->log('

Nothing to execute'); 110 | } 111 | 112 | public function beforeFinishingDeploy() 113 | { 114 | $this->log('

Nothing to execute'); 115 | } 116 | 117 | public function beforeStartingRollback() 118 | { 119 | $this->log('

Nothing to execute'); 120 | } 121 | 122 | public function beforeCancelingRollback() 123 | { 124 | $this->log('

Nothing to execute'); 125 | } 126 | 127 | public function beforeFinishingRollback() 128 | { 129 | $this->log('

Nothing to execute'); 130 | } 131 | 132 | public function initialize(Context $context): void 133 | { 134 | $this->context = $context; 135 | $this->logger = new Logger($context); 136 | $this->taskRunner = new TaskRunner($this->context->isDryRun(), $this->logger); 137 | $this->log('

Initializing configuration'); 138 | 139 | $this->log('

Processing the configuration options of the deployer class'); 140 | $this->config = new ConfigurationAdapter($this->configure()); 141 | $this->log($this->config); 142 | $this->log('

Checking technical requirements'); 143 | $this->checkRequirements(); 144 | } 145 | 146 | abstract protected function getConfigBuilder(); 147 | 148 | abstract protected function configure(); 149 | 150 | final protected function getContext(): Context 151 | { 152 | return $this->context; 153 | } 154 | 155 | final protected function getServers(): ServerRepository 156 | { 157 | return $this->config->get('servers'); 158 | } 159 | 160 | final protected function log(string $message): void 161 | { 162 | $this->logger->log($message); 163 | } 164 | 165 | final protected function runLocal(string $command): TaskCompleted 166 | { 167 | $task = new Task([$this->getContext()->getLocalHost()], $command, $this->getCommandEnvVars()); 168 | 169 | return $this->taskRunner->run($task)[0]; 170 | } 171 | 172 | /** 173 | * @return TaskCompleted[] 174 | */ 175 | final protected function runRemote(string $command, array $roles = [Server::ROLE_APP]): array 176 | { 177 | $task = new Task($this->getServers()->findByRoles($roles), $command, $this->getCommandEnvVars()); 178 | 179 | return $this->taskRunner->run($task); 180 | } 181 | 182 | final protected function runOnServer(string $command, Server $server): TaskCompleted 183 | { 184 | $task = new Task([$server], $command, $this->getCommandEnvVars()); 185 | 186 | return $this->taskRunner->run($task)[0]; 187 | } 188 | 189 | // this method checks that any file or directory that goes into "rm -rf" command is 190 | // relative to the project dir. This safeguard will prevent catastrophic errors 191 | // related to removing the wrong file or directory on the server. 192 | final protected function safeDelete(Server $server, array $absolutePaths): void 193 | { 194 | $deployDir = $server->get(Property::deploy_dir); 195 | $pathsToDelete = []; 196 | foreach ($absolutePaths as $path) { 197 | if (Str::startsWith($path, $deployDir)) { 198 | $pathsToDelete[] = $path; 199 | } else { 200 | $this->log(sprintf('Skipping the unsafe deletion of "%s" because it\'s not relative to the project directory.', $path)); 201 | } 202 | } 203 | 204 | if (empty($pathsToDelete)) { 205 | $this->log('There are no paths to delete.'); 206 | } 207 | 208 | $this->runOnServer(sprintf('rm -rf %s', implode(' ', $pathsToDelete)), $server); 209 | } 210 | 211 | private function getCommandEnvVars(): array 212 | { 213 | $symfonyEnvironment = $this->getConfig(Option::symfonyEnvironment); 214 | $symfonyEnvironmentEnvVarName = $this->getConfig('_symfonyEnvironmentEnvVarName'); 215 | $envVars = null !== $symfonyEnvironment ? [$symfonyEnvironmentEnvVarName => $symfonyEnvironment] : []; 216 | 217 | return $envVars; 218 | } 219 | 220 | private function checkRequirements(): void 221 | { 222 | /** @var AbstractRequirement[] $requirements */ 223 | $requirements = $this->getRequirements(); 224 | 225 | if (empty($requirements)) { 226 | $this->logger->log('

No requirements defined'); 227 | } 228 | 229 | foreach ($requirements as $requirement) { 230 | $this->taskRunner->run($requirement->getChecker()); 231 | $this->log($requirement->getMessage()); 232 | } 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/Deployer/CustomDeployer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Deployer; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Configuration\CustomConfiguration; 15 | use EasyCorp\Bundle\EasyDeployBundle\Requirement\AllowsLoginViaSsh; 16 | use EasyCorp\Bundle\EasyDeployBundle\Requirement\CommandExists; 17 | 18 | /** 19 | * Used when the deployment process is completely customized. Nothing is done or 20 | * executed for you, but you can leverage the SSH toolkit to run commands on 21 | * remote servers. It's similar to using Python's Fabric. 22 | */ 23 | abstract class CustomDeployer extends AbstractDeployer 24 | { 25 | public function getConfigBuilder(): CustomConfiguration 26 | { 27 | return new CustomConfiguration(); 28 | } 29 | 30 | public function getRequirements(): array 31 | { 32 | return [ 33 | new CommandExists([$this->getContext()->getLocalHost()], 'ssh'), 34 | new AllowsLoginViaSsh($this->getServers()->findAll()), 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Deployer/DefaultDeployer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Deployer; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Configuration\DefaultConfiguration; 15 | use EasyCorp\Bundle\EasyDeployBundle\Configuration\Option; 16 | use EasyCorp\Bundle\EasyDeployBundle\Exception\InvalidConfigurationException; 17 | use EasyCorp\Bundle\EasyDeployBundle\Requirement\AllowsLoginViaSsh; 18 | use EasyCorp\Bundle\EasyDeployBundle\Requirement\CommandExists; 19 | use EasyCorp\Bundle\EasyDeployBundle\Server\Property; 20 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 21 | use EasyCorp\Bundle\EasyDeployBundle\Task\TaskCompleted; 22 | 23 | abstract class DefaultDeployer extends AbstractDeployer 24 | { 25 | private $remoteProjectDirHasBeenCreated = false; 26 | private $remoteSymLinkHasBeenCreated = false; 27 | 28 | public function getConfigBuilder(): DefaultConfiguration 29 | { 30 | return new DefaultConfiguration($this->getContext()->getLocalProjectRootDir()); 31 | } 32 | 33 | public function getRequirements(): array 34 | { 35 | $requirements = []; 36 | $localhost = $this->getContext()->getLocalHost(); 37 | $allServers = $this->getServers()->findAll(); 38 | $appServers = $this->getServers()->findByRoles([Server::ROLE_APP]); 39 | 40 | $requirements[] = new CommandExists([$localhost], 'git'); 41 | $requirements[] = new CommandExists([$localhost], 'ssh'); 42 | 43 | $requirements[] = new AllowsLoginViaSsh($allServers); 44 | $requirements[] = new CommandExists($appServers, $this->getConfig(Option::remoteComposerBinaryPath)); 45 | if ('acl' === $this->getConfig(Option::permissionMethod)) { 46 | $requirements[] = new CommandExists($appServers, 'setfacl'); 47 | } 48 | 49 | return $requirements; 50 | } 51 | 52 | final public function deploy(): void 53 | { 54 | $this->initializeServerOptions(); 55 | $this->createRemoteDirectoryLayout(); 56 | $this->remoteProjectDirHasBeenCreated = true; 57 | 58 | $this->log('Executing beforeUpdating hook'); 59 | $this->beforeUpdating(); 60 | $this->log('

Updating app code'); 61 | $this->doUpdateCode(); 62 | 63 | $this->log('Executing beforePreparing hook'); 64 | $this->beforePreparing(); 65 | $this->log('

Preparing app'); 66 | $this->doCreateCacheDir(); 67 | $this->doCreateLogDir(); 68 | $this->doCreateSharedDirs(); 69 | $this->doCreateSharedFiles(); 70 | $this->doSetPermissions(); 71 | $this->doInstallDependencies(); 72 | $this->doInstallWebAssets(); 73 | $this->doDumpAsseticAssets(); 74 | 75 | $this->log('Executing beforeOptimizing hook'); 76 | $this->beforeOptimizing(); 77 | $this->log('

Optimizing app'); 78 | $this->doWarmupCache(); 79 | $this->doClearControllers(); 80 | $this->doOptimizeComposer(); 81 | 82 | $this->log('Executing beforePublishing hook'); 83 | $this->beforePublishing(); 84 | $this->log('

Publishing app'); 85 | $this->doCreateSymlink(); 86 | $this->remoteSymLinkHasBeenCreated = true; 87 | $this->doResetOpCache(); 88 | $this->doKeepReleases(); 89 | } 90 | 91 | final public function cancelDeploy(): void 92 | { 93 | if (!$this->remoteSymLinkHasBeenCreated && !$this->remoteProjectDirHasBeenCreated) { 94 | $this->log('

No changes need to be reverted on remote servers (neither the remote project dir nor the symlink were created)'); 95 | } 96 | 97 | if ($this->remoteSymLinkHasBeenCreated) { 98 | $this->doSymlinkToPreviousRelease(); 99 | } 100 | 101 | if ($this->remoteProjectDirHasBeenCreated) { 102 | $this->doDeleteLastReleaseDirectory(); 103 | } 104 | } 105 | 106 | final public function rollback(): void 107 | { 108 | $this->initializeServerOptions(); 109 | 110 | $this->log('Executing beforeRollingBack hook'); 111 | $this->beforeRollingBack(); 112 | 113 | $this->doCheckPreviousReleases(); 114 | $this->doSymlinkToPreviousRelease(); 115 | $this->doDeleteLastReleaseDirectory(); 116 | } 117 | 118 | public function beforeUpdating() 119 | { 120 | $this->log('

Nothing to execute'); 121 | } 122 | 123 | public function beforePreparing() 124 | { 125 | $this->log('

Nothing to execute'); 126 | } 127 | 128 | public function beforeOptimizing() 129 | { 130 | $this->log('

Nothing to execute'); 131 | } 132 | 133 | public function beforePublishing() 134 | { 135 | $this->log('

Nothing to execute'); 136 | } 137 | 138 | public function beforeRollingBack() 139 | { 140 | $this->log('

Nothing to execute'); 141 | } 142 | 143 | private function doCheckPreviousReleases(): void 144 | { 145 | $this->log('

Getting the previous releases dirs'); 146 | $results = $this->runRemote('ls -r1 {{ deploy_dir }}/releases'); 147 | 148 | if ($this->getContext()->isDryRun()) { 149 | return; 150 | } 151 | 152 | foreach ($results as $result) { 153 | $numReleases = count(array_filter(explode("\n", $result->getOutput()))); 154 | 155 | if ($numReleases < 2) { 156 | throw new \RuntimeException(sprintf('The application cannot be rolled back because the "%s" server has only 1 release and it\'s not possible to roll back to a previous version.', $result->getServer())); 157 | } 158 | } 159 | } 160 | 161 | private function doSymlinkToPreviousRelease(): void 162 | { 163 | $this->log('

Reverting the current symlink to the previous version'); 164 | $this->runRemote('export _previous_release_dirname=$(ls -r1 {{ deploy_dir }}/releases | head -n 2 | tail -n 1) && rm -f {{ deploy_dir }}/current && ln -s {{ deploy_dir }}/releases/$_previous_release_dirname {{ deploy_dir }}/current'); 165 | } 166 | 167 | private function doDeleteLastReleaseDirectory(): void 168 | { 169 | // this is needed to avoid rolling back in the future to this version 170 | $this->log('

Deleting the last release directory'); 171 | $this->runRemote('export _last_release_dirname=$(ls -r1 {{ deploy_dir }}/releases | head -n 1) && rm -fr {{ deploy_dir }}/releases/$_last_release_dirname'); 172 | } 173 | 174 | private function initializeServerOptions(): void 175 | { 176 | $this->log('

Initializing server options'); 177 | 178 | /** @var Server[] $allServers */ 179 | $allServers = array_merge([$this->getContext()->getLocalHost()], $this->getServers()->findAll()); 180 | foreach ($allServers as $server) { 181 | if (true === $this->getConfig(Option::useSshAgentForwarding)) { 182 | $this->log(sprintf('

Enabling SSH agent forwarding for %s server', $server)); 183 | } 184 | $server->set(Property::use_ssh_agent_forwarding, $this->getConfig(Option::useSshAgentForwarding)); 185 | } 186 | 187 | $appServers = $this->getServers()->findByRoles([Server::ROLE_APP]); 188 | foreach ($appServers as $server) { 189 | $this->log(sprintf('

Setting the %s property for %s server', Property::deploy_dir, $server)); 190 | $server->set(Property::deploy_dir, $this->getConfig(Option::deployDir)); 191 | } 192 | } 193 | 194 | private function initializeDirectoryLayout(Server $server): void 195 | { 196 | $this->log('

Initializing server directory layout'); 197 | 198 | $remoteProjectDir = $server->get(Property::project_dir); 199 | $server->set(Property::bin_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::binDir))); 200 | $server->set(Property::config_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::configDir))); 201 | $server->set(Property::cache_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::cacheDir))); 202 | $server->set(Property::log_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::logDir))); 203 | $server->set(Property::src_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::srcDir))); 204 | $server->set(Property::templates_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::templatesDir))); 205 | $server->set(Property::web_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::webDir))); 206 | 207 | // this is needed because some projects use a binary directory different than the default one of their Symfony version 208 | $server->set(Property::console_bin, sprintf('%s %s/console', $this->getConfig(Option::remotePhpBinaryPath), $this->getConfig(Option::binDir) ? $server->get(Property::bin_dir) : $this->findConsoleBinaryPath($server))); 209 | } 210 | 211 | // this is needed because it's common for Smyfony projects to use binary directories 212 | // different from their Symfony version. For example: Symfony 2 projects that upgrade 213 | // to Symfony 3 but still use app/console instead of bin/console 214 | private function findConsoleBinaryPath(Server $server): string 215 | { 216 | $symfonyConsoleBinaries = ['{{ project_dir }}/app/console', '{{ project_dir }}/bin/console']; 217 | foreach ($symfonyConsoleBinaries as $consoleBinary) { 218 | $localConsoleBinary = $this->getContext()->getLocalHost()->resolveProperties($consoleBinary); 219 | if (is_executable($localConsoleBinary)) { 220 | return $server->resolveProperties($consoleBinary); 221 | } 222 | } 223 | 224 | if (null === $server->get(Property::console_bin)) { 225 | throw new InvalidConfigurationException(sprintf('The "console" binary of your Symfony application is not available in any of the following directories: %s. Configure the "binDir" option and set it to the directory that contains the "console" binary.', implode(', ', $symfonyConsoleBinaries))); 226 | } 227 | } 228 | 229 | private function createRemoteDirectoryLayout(): void 230 | { 231 | $this->log('

Creating the remote directory layout'); 232 | $this->runRemote('mkdir -p {{ deploy_dir }} && mkdir -p {{ deploy_dir }}/releases && mkdir -p {{ deploy_dir }}/shared'); 233 | 234 | /** @var TaskCompleted[] $results */ 235 | $results = $this->runRemote('export _release_path="{{ deploy_dir }}/releases/$(date +%Y%m%d%H%M%S)" && mkdir -p $_release_path && echo $_release_path'); 236 | foreach ($results as $result) { 237 | $remoteProjectDir = $this->getContext()->isDryRun() ? '(the remote project_dir)' : $result->getTrimmedOutput(); 238 | $result->getServer()->set(Property::project_dir, $remoteProjectDir); 239 | $this->initializeDirectoryLayout($result->getServer()); 240 | } 241 | } 242 | 243 | private function doGetcodeRevision(): string 244 | { 245 | $this->log('

Getting the revision ID of the code repository'); 246 | $result = $this->runLocal(sprintf('git ls-remote %s %s', $this->getConfig(Option::repositoryUrl), $this->getConfig(Option::repositoryBranch))); 247 | $revision = explode("\t", $result->getTrimmedOutput())[0]; 248 | if ($this->getContext()->isDryRun()) { 249 | $revision = '(the code revision)'; 250 | } 251 | $this->log(sprintf('

Code revision hash = %s', $revision)); 252 | 253 | return $revision; 254 | } 255 | 256 | private function doUpdateCode(): void 257 | { 258 | $repositoryRevision = $this->doGetcodeRevision(); 259 | 260 | $this->log('

Updating code base with remote_cache strategy'); 261 | $this->runRemote(sprintf('if [ -d {{ deploy_dir }}/repo ]; then cd {{ deploy_dir }}/repo && git fetch -q origin && git fetch --tags -q origin && git reset -q --hard %s && git clean -q -d -x -f; else git clone -q -b %s %s {{ deploy_dir }}/repo && cd {{ deploy_dir }}/repo && git checkout -q -b deploy %s; fi', $repositoryRevision, $this->getConfig(Option::repositoryBranch), $this->getConfig(Option::repositoryUrl), $repositoryRevision)); 262 | 263 | $this->log('

Copying the updated code to the new release directory'); 264 | $this->runRemote(sprintf('cp -RPp {{ deploy_dir }}/repo/* {{ project_dir }}')); 265 | } 266 | 267 | private function doCreateCacheDir(): void 268 | { 269 | $this->log('

Creating cache directory'); 270 | $this->runRemote('if [ -d {{ cache_dir }} ]; then rm -rf {{ cache_dir }}; fi; mkdir -p {{ cache_dir }}'); 271 | } 272 | 273 | private function doCreateLogDir(): void 274 | { 275 | $this->log('

Creating log directory'); 276 | $this->runRemote('if [ -d {{ log_dir }} ] ; then rm -rf {{ log_dir }}; fi; mkdir -p {{ log_dir }}'); 277 | } 278 | 279 | private function doCreateSharedDirs(): void 280 | { 281 | $this->log('

Creating symlinks for shared directories'); 282 | foreach ($this->getConfig(Option::sharedDirs) as $sharedDir) { 283 | $this->runRemote(sprintf('mkdir -p {{ deploy_dir }}/shared/%s', $sharedDir)); 284 | $this->runRemote(sprintf('if [ -d {{ project_dir }}/%s ] ; then rm -rf {{ project_dir }}/%s; fi', $sharedDir, $sharedDir)); 285 | $this->runRemote(sprintf('ln -nfs {{ deploy_dir }}/shared/%s {{ project_dir }}/%s', $sharedDir, $sharedDir)); 286 | } 287 | } 288 | 289 | private function doCreateSharedFiles(): void 290 | { 291 | $this->log('

Creating symlinks for shared files'); 292 | foreach ($this->getConfig(Option::sharedFiles) as $sharedFile) { 293 | $sharedFileParentDir = dirname($sharedFile); 294 | $this->runRemote(sprintf('mkdir -p {{ deploy_dir }}/shared/%s', $sharedFileParentDir)); 295 | $this->runRemote(sprintf('touch {{ deploy_dir }}/shared/%s', $sharedFile)); 296 | $this->runRemote(sprintf('ln -nfs {{ deploy_dir }}/shared/%s {{ project_dir }}/%s', $sharedFile, $sharedFile)); 297 | } 298 | } 299 | 300 | // this method was inspired by https://github.com/deployphp/deployer/blob/master/recipe/deploy/writable.php 301 | // (c) Anton Medvedev 302 | private function doSetPermissions(): void 303 | { 304 | $permissionMethod = $this->getConfig(Option::permissionMethod); 305 | $writableDirs = implode(' ', $this->getConfig(Option::writableDirs)); 306 | $this->log(sprintf('

Setting permissions for writable dirs using the "%s" method', $permissionMethod)); 307 | 308 | if ('chmod' === $permissionMethod) { 309 | $this->runRemote(sprintf('chmod -R %s %s', $this->getConfig(Option::permissionMode), $writableDirs)); 310 | 311 | return; 312 | } 313 | 314 | if ('chown' === $permissionMethod) { 315 | $this->runRemote(sprintf('sudo chown -RL %s %s', $this->getConfig(Option::permissionUser), $writableDirs)); 316 | 317 | return; 318 | } 319 | 320 | if ('chgrp' === $permissionMethod) { 321 | $this->runRemote(sprintf('sudo chgrp -RH %s %s', $this->getConfig(Option::permissionGroup), $writableDirs)); 322 | 323 | return; 324 | } 325 | 326 | if ('acl' === $permissionMethod) { 327 | $this->runRemote(sprintf('sudo setfacl -RL -m u:"%s":rwX -m u:`whoami`:rwX %s', $this->getConfig(Option::permissionUser), $writableDirs)); 328 | $this->runRemote(sprintf('sudo setfacl -dRL -m u:"%s":rwX -m u:`whoami`:rwX %s', $this->getConfig(Option::permissionUser), $writableDirs)); 329 | 330 | return; 331 | } 332 | 333 | throw new InvalidConfigurationException(sprintf('The "%s" permission method is not valid. Select one of the supported methods.', $permissionMethod)); 334 | } 335 | 336 | private function doInstallDependencies(): void 337 | { 338 | if (true === $this->getConfig(Option::updateRemoteComposerBinary)) { 339 | $this->log('

Self Updating the Composer binary'); 340 | $this->runRemote(sprintf('%s self-update', $this->getConfig(Option::remoteComposerBinaryPath))); 341 | } 342 | 343 | $this->log('

Installing Composer dependencies'); 344 | $this->runRemote(sprintf('%s install %s', $this->getConfig(Option::remoteComposerBinaryPath), $this->getConfig(Option::composerInstallFlags))); 345 | } 346 | 347 | private function doInstallWebAssets(): void 348 | { 349 | if (true !== $this->getConfig(Option::installWebAssets)) { 350 | return; 351 | } 352 | 353 | $this->log('

Installing web assets'); 354 | $this->runRemote(sprintf('{{ console_bin }} assets:install {{ web_dir }} --symlink --no-debug --env=%s', $this->getConfig(Option::symfonyEnvironment))); 355 | } 356 | 357 | private function doDumpAsseticAssets(): void 358 | { 359 | if (true !== $this->getConfig(Option::dumpAsseticAssets)) { 360 | return; 361 | } 362 | 363 | $this->log('

Dumping Assetic assets'); 364 | $this->runRemote(sprintf('{{ console_bin }} assetic:dump --no-debug --env=%s', $this->getConfig(Option::symfonyEnvironment))); 365 | } 366 | 367 | private function doWarmupCache(): void 368 | { 369 | if (true !== $this->getConfig(Option::warmupCache)) { 370 | return; 371 | } 372 | 373 | $this->log('

Warming up cache'); 374 | $this->runRemote(sprintf('{{ console_bin }} cache:warmup --no-debug --env=%s', $this->getConfig(Option::symfonyEnvironment))); 375 | $this->runRemote('chmod -R g+w {{ cache_dir }}'); 376 | } 377 | 378 | private function doClearControllers(): void 379 | { 380 | $this->log('

Clearing controllers'); 381 | foreach ($this->getServers()->findByRoles([Server::ROLE_APP]) as $server) { 382 | $absolutePaths = array_map(function ($relativePath) use ($server) { 383 | return $server->resolveProperties(sprintf('{{ project_dir }}/%s', $relativePath)); 384 | }, $this->getConfig(Option::controllersToRemove)); 385 | 386 | $this->safeDelete($server, $absolutePaths); 387 | } 388 | } 389 | 390 | private function doOptimizeComposer(): void 391 | { 392 | $this->log('

Optimizing Composer autoloader'); 393 | $this->runRemote(sprintf('%s dump-autoload %s', $this->getConfig(Option::remoteComposerBinaryPath), $this->getConfig(Option::composerOptimizeFlags))); 394 | } 395 | 396 | private function doCreateSymlink(): void 397 | { 398 | $this->log('

Updating the symlink'); 399 | $this->runRemote('rm -f {{ deploy_dir }}/current && ln -s {{ project_dir }} {{ deploy_dir }}/current'); 400 | } 401 | 402 | private function doResetOpCache(): void 403 | { 404 | if (null === $homepageUrl = $this->getConfig(Option::resetOpCacheFor)) { 405 | return; 406 | } 407 | 408 | $this->log('

Resetting the OPcache contents'); 409 | $phpScriptPath = sprintf('__easy_deploy_opcache_reset_%s.php', bin2hex(random_bytes(8))); 410 | $this->runRemote(sprintf('echo " {{ web_dir }}/%s && wget %s/%s && rm -f {{ web_dir }}/%s', $phpScriptPath, $homepageUrl, $phpScriptPath, $phpScriptPath)); 411 | } 412 | 413 | private function doKeepReleases(): void 414 | { 415 | if (-1 === $this->getConfig(Option::keepReleases)) { 416 | $this->log('

No releases to delete'); 417 | 418 | return; 419 | } 420 | 421 | $results = $this->runRemote('ls -1 {{ deploy_dir }}/releases'); 422 | foreach ($results as $result) { 423 | $this->deleteOldReleases($result->getServer(), explode("\n", $result->getTrimmedOutput())); 424 | } 425 | } 426 | 427 | private function deleteOldReleases(Server $server, array $releaseDirs): void 428 | { 429 | foreach ($releaseDirs as $releaseDir) { 430 | if (!preg_match('/\d{14}/', $releaseDir)) { 431 | $this->log(sprintf('[%s] Skipping cleanup of old releases; unexpected "%s" directory found (all directory names should be timestamps)', $server, $releaseDir)); 432 | 433 | return; 434 | } 435 | } 436 | 437 | if (count($releaseDirs) <= $this->getConfig(Option::keepReleases)) { 438 | $this->log(sprintf('[%s] No releases to delete (there are %d releases and the config keeps %d releases).', $server, count($releaseDirs), $this->getConfig(Option::keepReleases))); 439 | 440 | return; 441 | } 442 | 443 | $relativeDirsToRemove = array_slice($releaseDirs, 0, -$this->getConfig(Option::keepReleases)); 444 | $absoluteDirsToRemove = array_map(function ($v) { 445 | return sprintf('%s/releases/%s', $this->getConfig(Option::deployDir), $v); 446 | }, $relativeDirsToRemove); 447 | 448 | // the command must be run only on one server because the timestamps are 449 | // different for all servers, even when they belong to the same deploy and 450 | // because new servers may have been added to the deploy and old releases don't exist on them 451 | $this->log(sprintf('Deleting these old release directories: %s', implode(', ', $absoluteDirsToRemove))); 452 | $this->safeDelete($server, $absoluteDirsToRemove); 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /src/EasyDeployBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle; 13 | 14 | use Symfony\Component\HttpKernel\Bundle\Bundle; 15 | 16 | class EasyDeployBundle extends Bundle 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/InvalidConfigurationException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Exception; 13 | 14 | class InvalidConfigurationException extends \InvalidArgumentException 15 | { 16 | public function __construct(string $message) 17 | { 18 | parent::__construct($message); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/ServerConfigurationException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Exception; 13 | 14 | class ServerConfigurationException extends \InvalidArgumentException 15 | { 16 | public function __construct(string $serverName, string $cause) 17 | { 18 | parent::__construct(sprintf('The connection string for "%s" server is wrong: %s.', $serverName, $cause)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Helper/Str.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Helper; 13 | 14 | /** 15 | * This helper class encapsulates common string operations not available in a 16 | * user-friendly way in PHP and other operations specific to the application. 17 | */ 18 | class Str 19 | { 20 | public static function startsWith(string $haystack, string $needle): bool 21 | { 22 | return '' !== $needle && 0 === mb_strpos($haystack, $needle); 23 | } 24 | 25 | public static function endsWith(string $haystack, string $needle): bool 26 | { 27 | return $needle === mb_substr($haystack, -mb_strlen($needle)); 28 | } 29 | 30 | public static function contains(string $haystack, string $needle): bool 31 | { 32 | return '' !== $needle && false !== mb_strpos($haystack, $needle); 33 | } 34 | 35 | public static function lineSeparator(string $char = '-'): string 36 | { 37 | return str_repeat($char, 80); 38 | } 39 | 40 | public static function prefix($text, string $prefix): string 41 | { 42 | $text = is_array($text) ? $text : explode(PHP_EOL, $text); 43 | 44 | return implode(PHP_EOL, array_map(function ($line) use ($prefix) { 45 | return $prefix.$line; 46 | }, $text)); 47 | } 48 | 49 | public static function stringify($value): string 50 | { 51 | if (is_resource($value)) { 52 | return 'PHP Resource'; 53 | } 54 | 55 | if (is_bool($value)) { 56 | return $value ? 'true' : 'false'; 57 | } 58 | 59 | if (is_array($value)) { 60 | return json_encode($value, JSON_UNESCAPED_SLASHES); 61 | } 62 | 63 | if (is_object($value) && !method_exists($value, '__toString')) { 64 | return json_encode($value, JSON_UNESCAPED_SLASHES); 65 | } 66 | 67 | return (string) $value; 68 | } 69 | 70 | public static function formatAsTable(array $data, bool $sortKeys = true): string 71 | { 72 | if ($sortKeys) { 73 | ksort($data); 74 | } 75 | 76 | $arrayAsString = ''; 77 | $longestArrayKeyLength = max(array_map('strlen', array_keys($data))); 78 | foreach ($data as $key => $value) { 79 | $arrayAsString .= sprintf("%s%s : %s\n", $key, str_repeat(' ', $longestArrayKeyLength - mb_strlen($key)), self::stringify($value)); 80 | } 81 | 82 | return rtrim($arrayAsString); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Helper/SymfonyConfigPathGuesser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Helper; 13 | 14 | /** 15 | * @author Jules Pietri 16 | */ 17 | class SymfonyConfigPathGuesser 18 | { 19 | private const LEGACY_CONFIG_DIR = '%s/app/config'; 20 | private const CONFIG_DIR = '%s/config'; 21 | 22 | public static function guess(string $projectDir, string $stage): string 23 | { 24 | if (is_dir($configDir = sprintf(self::CONFIG_DIR, $projectDir))) { 25 | return sprintf('%s/%s/deploy.php', $configDir, $stage); 26 | } 27 | 28 | if (is_dir($configDir = sprintf(self::LEGACY_CONFIG_DIR, $projectDir))) { 29 | return sprintf('%s/deploy_%s.php', $configDir, $stage); 30 | } 31 | 32 | throw new \RuntimeException(sprintf('None of the usual Symfony config dirs exist in the application. Create one of these dirs before continuing: "%s" or "%s".', self::CONFIG_DIR, self::LEGACY_CONFIG_DIR)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Logger.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Helper\Str; 15 | use Symfony\Component\Console\Formatter\OutputFormatter; 16 | use Symfony\Component\Console\Formatter\OutputFormatterStyle; 17 | use Symfony\Component\Filesystem\Filesystem; 18 | 19 | final class Logger 20 | { 21 | private $isDebug; 22 | private $output; 23 | private $logFilePath; 24 | 25 | public function __construct(Context $context) 26 | { 27 | $this->isDebug = $context->isDebug(); 28 | 29 | $this->output = $context->getOutput(); 30 | $this->output->setFormatter($this->createOutputFormatter()); 31 | 32 | $this->logFilePath = $context->getLogFilePath(); 33 | $this->initializeLogFile(); 34 | } 35 | 36 | public function log(string $message): void 37 | { 38 | $isPriorityMessage = Str::startsWith($message, '

'); 39 | $isResultMessage = Str::contains($message, '') || Str::contains($message, ''); 40 | if ($this->isDebug || $isPriorityMessage || $isResultMessage) { 41 | $this->output->writeln($message); 42 | } 43 | 44 | $this->writeToLogFile($message); 45 | } 46 | 47 | private function createOutputFormatter(): OutputFormatter 48 | { 49 | return new OutputFormatter(true, [ 50 | 'command' => new OutputFormatterStyle('yellow', null), 51 | 'error' => new OutputFormatterStyle('red', null, ['bold', 'reverse']), 52 | 'hook' => new OutputFormatterStyle('blue', null, ['bold']), 53 | 'server' => new OutputFormatterStyle('magenta', null), 54 | 'stream' => new OutputFormatterStyle(null, null), 55 | 'success' => new OutputFormatterStyle('green', null, ['bold', 'reverse']), 56 | 'ok' => new OutputFormatterStyle('green', null), 57 | 'warn' => new OutputFormatterStyle('yellow', null), 58 | 'h1' => new OutputFormatterStyle('blue', null, ['bold']), 59 | 'h2' => new OutputFormatterStyle(null, null, []), 60 | 'h3' => new OutputFormatterStyle(null, null, []), 61 | ]); 62 | } 63 | 64 | private function initializeLogFile(): void 65 | { 66 | (new Filesystem())->dumpFile($this->logFilePath, ''); 67 | $this->writeToLogFile(sprintf("%s\nDeployment started at %s\n%s", Str::lineSeparator('='), date('r'), Str::lineSeparator('='))); 68 | } 69 | 70 | private function writeToLogFile(string $message): void 71 | { 72 | $loggedMessage = $this->processLogMessageForFile($message); 73 | file_put_contents($this->logFilePath, $loggedMessage.PHP_EOL, FILE_APPEND); 74 | } 75 | 76 | private function processLogMessageForFile(string $message): string 77 | { 78 | $replacements = [ 79 | '/(.*)<\/>/' => '"$1"', 80 | '/(.*)<\/>/' => '$1', 81 | '/(.*)<\/>/' => '$1', 82 | '/(.*)<\/>/' => '__ $1 __', 83 | '/(.*)<\/>/' => '$1', 84 | '/(.*)<\/>/' => '$1', 85 | '/

(.*)<\/>/' => "\n===> $1", 86 | '/

(.*)<\/>/' => '---> $1', 87 | '/

(.*)<\/>/' => '$1', 88 | '/(.*)<\/>/' => sprintf("\n%s\n$1\n%s\n", Str::lineSeparator(), Str::lineSeparator()), 89 | '/(.*)<\/>/' => sprintf("\n%s\n$1\n%s\n", Str::lineSeparator('*'), Str::lineSeparator('*')), 90 | ]; 91 | 92 | return preg_replace(array_keys($replacements), array_values($replacements), $message); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Requirement/AbstractRequirement.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Requirement; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 15 | use EasyCorp\Bundle\EasyDeployBundle\Task\Task; 16 | 17 | abstract class AbstractRequirement 18 | { 19 | /** @var Server[] */ 20 | private $servers; 21 | 22 | public function __construct(array $servers) 23 | { 24 | $this->servers = $servers; 25 | } 26 | 27 | public function getServers(): array 28 | { 29 | return $this->servers; 30 | } 31 | 32 | abstract public function getChecker(): Task; 33 | 34 | abstract public function getMessage(): string; 35 | } 36 | -------------------------------------------------------------------------------- /src/Requirement/AllowsLoginViaSsh.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Requirement; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Task\Task; 15 | 16 | class AllowsLoginViaSsh extends AbstractRequirement 17 | { 18 | public function getMessage(): string 19 | { 20 | return '[OK] The server allows to login via SSH from the local machine'; 21 | } 22 | 23 | public function getChecker(): Task 24 | { 25 | $shellCommand = sprintf('echo %s', mt_rand()); 26 | 27 | return new Task($this->getServers(), $shellCommand); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Requirement/CommandExists.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Requirement; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Task\Task; 15 | 16 | class CommandExists extends AbstractRequirement 17 | { 18 | private $commandName; 19 | 20 | public function __construct(array $servers, string $commandName) 21 | { 22 | parent::__construct($servers); 23 | $this->commandName = $commandName; 24 | } 25 | 26 | public function getMessage(): string 27 | { 28 | return sprintf('[OK] %s command exists', $this->commandName); 29 | } 30 | 31 | public function getChecker(): Task 32 | { 33 | $shellCommand = sprintf('%s %s', $this->isWindows() ? 'where' : 'which', $this->commandName); 34 | 35 | return new Task($this->getServers(), $shellCommand); 36 | } 37 | 38 | private function isWindows(): bool 39 | { 40 | return '\\' === DIRECTORY_SEPARATOR; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | container.hasParameter('kernel.project_dir') ? parameter('kernel.project_dir') : parameter('kernel.root_dir') ~ '/..' 11 | %kernel.logs_dir% 12 | 13 | 14 | 15 | 16 | container.hasParameter('kernel.project_dir') ? parameter('kernel.project_dir') : parameter('kernel.root_dir') ~ '/..' 17 | %kernel.logs_dir% 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Resources/skeleton/deploy.php.dist: -------------------------------------------------------------------------------- 1 | getConfigBuilder() 10 | // SSH connection string to connect to the remote server (format: user@host-or-IP:port-number) 11 | ->server('user@hostname') 12 | // the absolute path of the remote server directory where the project is deployed 13 | ->deployDir('/var/www/vhosts/symfony-demo') 14 | // the URL of the Git repository where the project code is hosted 15 | ->repositoryUrl('https://github.com/symfony/symfony-demo') 16 | // the repository branch to deploy 17 | ->repositoryBranch('master') 18 | ; 19 | } 20 | 21 | // run some local or remote commands before the deployment is started 22 | public function beforeStartingDeploy() 23 | { 24 | // $this->runLocal('./vendor/bin/simple-phpunit'); 25 | } 26 | 27 | // run some local or remote commands after the deployment is finished 28 | public function beforeFinishingDeploy() 29 | { 30 | // $this->runRemote('{{ console_bin }} app:my-task-name'); 31 | // $this->runLocal('say "The deployment has finished."'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/Server/Property.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Server; 13 | 14 | /** 15 | * It defines the names of the reserved server properties to avoid using "magic 16 | * strings" in the application. It's common to define PHP constants 17 | * in uppercase, but these are in snake_case because of how properties are used. 18 | * Properties can be included in commands using a special syntax 19 | * (e.g. {{ property-name }}). Using uppercase would create ugly commands 20 | * (e.g. 'cd {{ BIN_DIR }}' instead of 'cd {{ bind_dir }}'). 21 | */ 22 | final class Property 23 | { 24 | const bin_dir = 'bin_dir'; 25 | const config_dir = 'config_dir'; 26 | const console_bin = 'console_bin'; 27 | const cache_dir = 'cache_dir'; 28 | const deploy_dir = 'deploy_dir'; 29 | const log_dir = 'log_dir'; 30 | const project_dir = 'project_dir'; 31 | const src_dir = 'src_dir'; 32 | const templates_dir = 'templates_dir'; 33 | const use_ssh_agent_forwarding = 'use_ssh_agent_forwarding'; 34 | const web_dir = 'web_dir'; 35 | } 36 | -------------------------------------------------------------------------------- /src/Server/Server.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Server; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Exception\ServerConfigurationException; 15 | use EasyCorp\Bundle\EasyDeployBundle\Helper\Str; 16 | use Symfony\Component\HttpFoundation\ParameterBag; 17 | 18 | class Server 19 | { 20 | const ROLE_APP = 'app'; 21 | private const LOCALHOST_ADDRESSES = ['localhost', 'local', '127.0.0.1']; 22 | private $roles; 23 | private $user; 24 | private $host; 25 | private $port; 26 | private $properties; 27 | 28 | public function __construct(string $dsn, array $roles = [self::ROLE_APP], array $properties = []) 29 | { 30 | $this->roles = $roles; 31 | $this->properties = new ParameterBag($properties); 32 | 33 | // add the 'ssh://' scheme so the URL parsing works as expected 34 | $params = parse_url(Str::startsWith($dsn, 'ssh://') ? $dsn : 'ssh://'.$dsn); 35 | 36 | $this->user = $params['user'] ?? null; 37 | 38 | if (!isset($params['host'])) { 39 | throw new ServerConfigurationException($dsn, 'The host is missing (define it as an IP address or a host name)'); 40 | } 41 | $this->host = $params['host']; 42 | 43 | $this->port = $params['port'] ?? null; 44 | } 45 | 46 | public function __toString(): string 47 | { 48 | return sprintf('%s%s', $this->getUser() ? $this->getUser().'@' : '', $this->getHost()); 49 | } 50 | 51 | public function isLocalHost(): bool 52 | { 53 | return in_array($this->getHost(), self::LOCALHOST_ADDRESSES, true); 54 | } 55 | 56 | public function resolveProperties(string $expression): string 57 | { 58 | $definedProperties = $this->properties; 59 | $resolved = preg_replace_callback('/(\{\{\s*(?.+)\s*\}\})/U', function (array $matches) use ($definedProperties, $expression) { 60 | $propertyName = trim($matches['propertyName']); 61 | if (!$definedProperties->has($propertyName)) { 62 | throw new \InvalidArgumentException(sprintf('The "%s" property in "%s" expression is not a valid server property.', $propertyName, $expression)); 63 | } 64 | 65 | return $definedProperties->get($propertyName); 66 | }, $expression); 67 | 68 | return $resolved; 69 | } 70 | 71 | public function getProperties(): array 72 | { 73 | return $this->properties->all(); 74 | } 75 | 76 | public function get(string $propertyName, $default = null) 77 | { 78 | return $this->properties->get($propertyName, $default); 79 | } 80 | 81 | public function set(string $propertyName, $value): void 82 | { 83 | $this->properties->set($propertyName, $value); 84 | } 85 | 86 | public function has(string $propertyName): bool 87 | { 88 | return $this->properties->has($propertyName); 89 | } 90 | 91 | public function getSshConnectionString(): string 92 | { 93 | if ($this->isLocalHost()) { 94 | return ''; 95 | } 96 | 97 | return sprintf('ssh %s%s%s%s', 98 | $this->properties->get('use_ssh_agent_forwarding') ? '-A ' : '', 99 | $this->user ?? '', 100 | $this->user ? '@'.$this->host : $this->host, 101 | $this->port ? ' -p '.$this->port : '' 102 | ); 103 | } 104 | 105 | public function getRoles(): array 106 | { 107 | return $this->roles; 108 | } 109 | 110 | public function getUser(): ?string 111 | { 112 | return $this->user; 113 | } 114 | 115 | public function getHost(): string 116 | { 117 | return $this->host; 118 | } 119 | 120 | public function getPort(): ?int 121 | { 122 | return $this->port; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Server/ServerRepository.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Server; 13 | 14 | /** 15 | * It implements the "Repository" pattern to store the servers involved in the 16 | * deployment and provide some helper methods to find and filter those servers. 17 | */ 18 | class ServerRepository 19 | { 20 | /** @var Server[] $servers */ 21 | private $servers = []; 22 | 23 | public function __toString(): string 24 | { 25 | return implode(', ', $this->servers); 26 | } 27 | 28 | public function add(Server $server): void 29 | { 30 | $this->servers[] = $server; 31 | } 32 | 33 | public function findAll(): array 34 | { 35 | return $this->servers; 36 | } 37 | 38 | /** 39 | * @return Server[] 40 | */ 41 | public function findByRoles(array $roles): array 42 | { 43 | return array_filter($this->servers, function (Server $server) use ($roles) { 44 | return !empty(array_intersect($roles, $server->getRoles())); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Task/Task.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Task; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 15 | 16 | class Task 17 | { 18 | /** @var Server[] $servers */ 19 | private $servers; 20 | private $shellCommand; 21 | private $envVars; 22 | 23 | public function __construct(array $servers, string $shellCommand, array $envVars = []) 24 | { 25 | if (empty($servers)) { 26 | throw new \InvalidArgumentException('The "servers" argument of a Task cannot be an empty array. Add at least one server.'); 27 | } 28 | 29 | $this->servers = $servers; 30 | $this->shellCommand = $shellCommand; 31 | $this->envVars = $envVars; 32 | } 33 | 34 | /** 35 | * @return Server[] 36 | */ 37 | public function getServers(): array 38 | { 39 | return $this->servers; 40 | } 41 | 42 | public function isLocal(): bool 43 | { 44 | foreach ($this->servers as $server) { 45 | if (!$server->isLocalHost()) { 46 | return false; 47 | } 48 | } 49 | 50 | return true; 51 | } 52 | 53 | public function isRemote(): bool 54 | { 55 | return !$this->isLocal(); 56 | } 57 | 58 | public function getShellCommand(): string 59 | { 60 | return $this->shellCommand; 61 | } 62 | 63 | public function getEnvVars(): array 64 | { 65 | return $this->envVars; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Task/TaskCompleted.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Task; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 15 | 16 | /** 17 | * It is an immutable object that encapsulates the result of executing a task 18 | * and provides helper methods to get all its information. 19 | */ 20 | class TaskCompleted 21 | { 22 | private $server; 23 | private $output; 24 | private $exitCode; 25 | 26 | public function __construct(Server $server, string $output, int $exitCode) 27 | { 28 | $this->server = $server; 29 | $this->output = $output; 30 | $this->exitCode = $exitCode; 31 | } 32 | 33 | public function isSuccessful(): bool 34 | { 35 | return 0 === $this->exitCode; 36 | } 37 | 38 | public function getServer(): Server 39 | { 40 | return $this->server; 41 | } 42 | 43 | public function getOutput(): string 44 | { 45 | return $this->output; 46 | } 47 | 48 | public function getTrimmedOutput(): string 49 | { 50 | return trim($this->output); 51 | } 52 | 53 | public function getExitCode(): int 54 | { 55 | return $this->exitCode; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Task/TaskRunner.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Task; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Helper\Str; 15 | use EasyCorp\Bundle\EasyDeployBundle\Logger; 16 | use EasyCorp\Bundle\EasyDeployBundle\Server\Property; 17 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 18 | use Symfony\Component\Process\Process; 19 | 20 | class TaskRunner 21 | { 22 | private $isDryRun; 23 | private $logger; 24 | 25 | public function __construct(bool $isDryRun, Logger $logger) 26 | { 27 | $this->isDryRun = $isDryRun; 28 | $this->logger = $logger; 29 | } 30 | 31 | /** 32 | * @return TaskCompleted[] 33 | */ 34 | public function run(Task $task): array 35 | { 36 | $results = []; 37 | foreach ($task->getServers() as $server) { 38 | $results[] = $this->doRun($server, $server->resolveProperties($task->getShellCommand()), $task->getEnvVars()); 39 | } 40 | 41 | return $results; 42 | } 43 | 44 | private function createProcess(string $shellCommand): Process 45 | { 46 | if (method_exists(Process::class, 'fromShellCommandline')) { 47 | return Process::fromShellCommandline($shellCommand); 48 | } 49 | 50 | return new Process($shellCommand); 51 | } 52 | 53 | private function doRun(Server $server, string $shellCommand, array $envVars): TaskCompleted 54 | { 55 | if ($server->has(Property::project_dir)) { 56 | $shellCommand = sprintf('cd %s && %s', $server->get(Property::project_dir), $shellCommand); 57 | } 58 | 59 | // env vars aren't set with $process->setEnv() because it causes problems 60 | // that can't be fully solved with inheritEnvironmentVariables() 61 | if (!empty($envVars)) { 62 | $envVarsAsString = http_build_query($envVars, '', ' '); 63 | // the ';' after the env vars makes them available to all commands, not only the first one 64 | // parenthesis create a sub-shell so the env vars don't affect to the parent shell 65 | $shellCommand = sprintf('(export %s; %s)', $envVarsAsString, $shellCommand); 66 | } 67 | 68 | $this->logger->log(sprintf('[%s] Executing command: %s', $server, $shellCommand)); 69 | 70 | if ($this->isDryRun) { 71 | return new TaskCompleted($server, '', 0); 72 | } 73 | 74 | if ($server->isLocalHost()) { 75 | $process = $this->createProcess($shellCommand); 76 | } else { 77 | $process = $this->createProcess(sprintf('%s %s', $server->getSshConnectionString(), escapeshellarg($shellCommand))); 78 | } 79 | 80 | $process->setTimeout(null); 81 | 82 | $process = $process->mustRun(function ($type, $buffer) { 83 | if (Process::ERR === $type) { 84 | $this->logger->log(Str::prefix(rtrim($buffer, PHP_EOL), '| err :: ')); 85 | } else { 86 | $this->logger->log(Str::prefix(rtrim($buffer, PHP_EOL), '| out :: ')); 87 | } 88 | }); 89 | 90 | return new TaskCompleted($server, $process->getOutput(), $process->getExitCode()); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/Configuration/ConfigurationAdapterTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Tests; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Configuration\ConfigurationAdapter; 15 | use EasyCorp\Bundle\EasyDeployBundle\Configuration\DefaultConfiguration; 16 | use EasyCorp\Bundle\EasyDeployBundle\Configuration\Option; 17 | use PHPUnit\Framework\TestCase; 18 | 19 | class ConfigurationAdapterTest extends TestCase 20 | { 21 | /** @var DefaultConfiguration */ 22 | private $config; 23 | 24 | protected function setUp() 25 | { 26 | $this->config = (new DefaultConfiguration(__DIR__)) 27 | ->sharedFilesAndDirs([]) 28 | ->server('host1') 29 | ->repositoryUrl('git@github.com:symfony/symfony-demo.git') 30 | ->repositoryBranch('staging') 31 | ->deployDir('/var/www/symfony-demo') 32 | ; 33 | } 34 | 35 | public function test_get_options() 36 | { 37 | $config = new ConfigurationAdapter($this->config); 38 | 39 | $this->assertSame('host1', (string) $config->get(Option::servers)->findAll()[0]); 40 | $this->assertSame('git@github.com:symfony/symfony-demo.git', $config->get(Option::repositoryUrl)); 41 | $this->assertSame('staging', $config->get(Option::repositoryBranch)); 42 | $this->assertSame('/var/www/symfony-demo', $config->get(Option::deployDir)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Configuration/DefaultConfigurationTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Tests; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Configuration\DefaultConfiguration; 15 | use PHPUnit\Framework\TestCase; 16 | 17 | class DefaultConfigurationTest extends TestCase 18 | { 19 | /** 20 | * @dataProvider provideHttpRepositoryUrls 21 | * @expectedException \EasyCorp\Bundle\EasyDeployBundle\Exception\InvalidConfigurationException 22 | * @expectedExceptionMessageRegExp /The repository URL must use the SSH syntax instead of the HTTPs syntax to make it work on any remote server. Replace "https?:\/\/.*\/symfony\/symfony-demo.git" by "git@.*:symfony\/symfony-demo.git"/ 23 | */ 24 | public function test_repository_url_protocol(string $url) 25 | { 26 | (new DefaultConfiguration(__DIR__)) 27 | ->repositoryUrl($url) 28 | ; 29 | } 30 | 31 | /** 32 | * @expectedException \EasyCorp\Bundle\EasyDeployBundle\Exception\InvalidConfigurationException 33 | * @expectedExceptionMessage The value of resetOpCacheFor option must be the valid URL of your homepage (it must start with http:// or https://). 34 | */ 35 | public function test_reset_opcache_for() 36 | { 37 | (new DefaultConfiguration(__DIR__)) 38 | ->resetOpCacheFor('symfony.com') 39 | ; 40 | } 41 | 42 | public function provideHttpRepositoryUrls() 43 | { 44 | yield ['http://github.com/symfony/symfony-demo.git']; 45 | yield ['https://github.com/symfony/symfony-demo.git']; 46 | yield ['http://bitbucket.org/symfony/symfony-demo.git']; 47 | yield ['https://bitbucket.org/symfony/symfony-demo.git']; 48 | yield ['http://gitlab.com/symfony/symfony-demo.git']; 49 | yield ['https://gitlab.com/symfony/symfony-demo.git']; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/ContextTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Tests; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Context; 15 | use EasyCorp\Bundle\EasyDeployBundle\Server\Property; 16 | use PHPUnit\Framework\TestCase; 17 | use Symfony\Component\Console\Input\ArrayInput; 18 | use Symfony\Component\Console\Output\NullOutput; 19 | 20 | class ContextTest extends TestCase 21 | { 22 | public function test_context_creates_localhost() 23 | { 24 | $context = new Context(new ArrayInput([]), new NullOutput(), __DIR__, __DIR__.'/deploy_prod.log', true, true); 25 | 26 | $this->assertSame('localhost', $context->getLocalHost()->getHost()); 27 | $this->assertSame(__DIR__, $context->getLocalHost()->get(Property::project_dir)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Helper/StrTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Tests; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Helper\Str; 15 | use PHPUnit\Framework\TestCase; 16 | 17 | class StrTest extends TestCase 18 | { 19 | /** @dataProvider startsWithProvider */ 20 | public function test_starts_with(string $haystack, string $needle, bool $expectedResult) 21 | { 22 | $this->assertSame($expectedResult, Str::startsWith($haystack, $needle)); 23 | } 24 | 25 | /** @dataProvider endsWithProvider */ 26 | public function test_ends_with(string $haystack, string $needle, bool $expectedResult) 27 | { 28 | $this->assertSame($expectedResult, Str::endsWith($haystack, $needle)); 29 | } 30 | 31 | /** @dataProvider containsProvider */ 32 | public function test_contains(string $haystack, string $needle, bool $expectedResult) 33 | { 34 | $this->assertSame($expectedResult, Str::contains($haystack, $needle)); 35 | } 36 | 37 | /** @dataProvider prefixProvider */ 38 | public function test_prefix($text, string $prefix, string $expectedResult) 39 | { 40 | $this->assertSame($expectedResult, Str::prefix($text, $prefix)); 41 | } 42 | 43 | /** @dataProvider stringifyProvider */ 44 | public function test_stringify($value, string $expectedResult) 45 | { 46 | $this->assertSame($expectedResult, Str::stringify($value)); 47 | } 48 | 49 | public function test_format_as_table() 50 | { 51 | $values = ['key1' => -3.14, 'key3 with long name' => ['a', 'b' => 2], 'key2' => 'aaa']; 52 | $result = <<assertSame($result, Str::formatAsTable($values)); 59 | } 60 | 61 | public function startsWithProvider() 62 | { 63 | yield ['', '', false]; 64 | yield ['abc', '', false]; 65 | yield ['abc', 'a', true]; 66 | yield ['abc', ' a', false]; 67 | yield ['abc', 'abc', true]; 68 | yield ['abc', ' abc', false]; 69 | yield ['abc', 'abcd', false]; 70 | yield ['

a bc', '

', true]; 71 | yield ['

a bc', 'a', false]; 72 | } 73 | 74 | public function endsWithProvider() 75 | { 76 | yield ['', '', true]; 77 | yield ['abc', '', false]; 78 | yield ['abc', 'c', true]; 79 | yield ['abc', 'c ', false]; 80 | yield ['abc', 'abc', true]; 81 | yield ['abc', 'abc ', false]; 82 | yield ['abc', 'aabc', false]; 83 | yield ['ab

c', '', true]; 84 | yield ['ab

c', 'c', false]; 85 | } 86 | 87 | public function containsProvider() 88 | { 89 | yield ['', '', false]; 90 | yield ['abc', '', false]; 91 | yield ['abc', 'a', true]; 92 | yield ['abc', 'b', true]; 93 | yield ['abc', 'c', true]; 94 | yield ['abc', 'ab', true]; 95 | yield ['abc', 'bc', true]; 96 | yield ['abc', 'ac', false]; 97 | yield ['abc', 'c ', false]; 98 | yield ['abc', 'abc', true]; 99 | yield ['abc', ' abc', false]; 100 | yield ['ab

c', '

', true]; 101 | yield ['ab

c', 'c', true]; 102 | yield ['ab

c', 'ab c', false]; 103 | } 104 | 105 | public function prefixProvider() 106 | { 107 | yield ['', '', '']; 108 | yield ['aaa', 'xxx', 'xxxaaa']; 109 | yield ["aaa\nbbb\nccc", 'xxx', "xxxaaa\nxxxbbb\nxxxccc"]; 110 | yield [['aaa', 'bbb', 'ccc'], 'xxx', "xxxaaa\nxxxbbb\nxxxccc"]; 111 | } 112 | 113 | public function stringifyProvider() 114 | { 115 | yield ['', '']; 116 | yield [fopen('php://memory', 'r+'), 'PHP Resource']; 117 | yield [true, 'true']; 118 | yield [false, 'false']; 119 | yield [-3.14, '-3.14']; 120 | yield [[1, 2, 3], '[1,2,3]']; 121 | yield [['a' => 'aaa', 'b' => '3.14', 'c' => ['a', 'b']], '{"a":"aaa","b":"3.14","c":["a","b"]}']; 122 | yield [new class() { 123 | public $a = 'aaa'; 124 | private $b = 'bbb'; 125 | }, '{"a":"aaa"}']; 126 | yield [new class() { 127 | public function __toString() 128 | { 129 | return 'aaa'; 130 | } 131 | }, 'aaa']; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /tests/Server/ServerRepositoryTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\EasyDeployBundle\Tests; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 15 | use EasyCorp\Bundle\EasyDeployBundle\Server\ServerRepository; 16 | use PHPUnit\Framework\TestCase; 17 | 18 | class ServerRepositoryTest extends TestCase 19 | { 20 | /** @var ServerRepository */ 21 | private $servers; 22 | 23 | protected function setUp() 24 | { 25 | $repository = new ServerRepository(); 26 | $repository->add(new Server('host0')); 27 | $repository->add(new Server('host1', [])); 28 | $repository->add(new Server('host2', ['app'])); 29 | $repository->add(new Server('host3', ['workers', 'worker1'])); 30 | $repository->add(new Server('host4', ['workers', 'worker2'])); 31 | $repository->add(new Server('host5', ['database'])); 32 | 33 | $this->servers = $repository; 34 | } 35 | 36 | public function test_find_all() 37 | { 38 | $servers = $this->servers->findAll(); 39 | $serverNames = array_values(array_map(function ($v) { return (string) $v; }, $servers)); 40 | 41 | $this->assertSame(['host0', 'host1', 'host2', 'host3', 'host4', 'host5'], $serverNames); 42 | } 43 | 44 | /** @dataProvider findByRolesProvider */ 45 | public function test_find_by_roles(array $roles, array $expectedResult) 46 | { 47 | $servers = $this->servers->findByRoles($roles); 48 | // array_values() is needed to reset the values of the keys 49 | $serverNames = array_values(array_map(function ($v) { return (string) $v; }, $servers)); 50 | 51 | $this->assertSame($expectedResult, $serverNames); 52 | } 53 | 54 | public function findByRolesProvider() 55 | { 56 | yield [[], []]; 57 | yield [['app'], ['host0', 'host2']]; 58 | yield [['workers'], ['host3', 'host4']]; 59 | yield [['worker1'], ['host3']]; 60 | yield [['worker2'], ['host4']]; 61 | yield [['database'], ['host5']]; 62 | yield [['database', 'workers'], ['host3', 'host4', 'host5']]; 63 | yield [['app', 'database', 'worker1'], ['host0', 'host2', 'host3', 'host5']]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Server/ServerTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\EasyDeployBundle\Tests; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Server\Property; 15 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 16 | use PHPUnit\Framework\TestCase; 17 | 18 | class ServerTest extends TestCase 19 | { 20 | /** @dataProvider dsnProvider */ 21 | public function test_dsn_parsing(string $dsn, string $expectedHost, ?string $expectedUser, ?int $expectedPort) 22 | { 23 | $server = new Server($dsn); 24 | 25 | $this->assertSame($expectedHost, $server->getHost()); 26 | $this->assertSame($expectedUser, $server->getUser()); 27 | $this->assertSame($expectedPort, $server->getPort()); 28 | } 29 | 30 | /** 31 | * @expectedException \EasyCorp\Bundle\EasyDeployBundle\Exception\ServerConfigurationException 32 | * @expectedExceptionMessage The host is missing (define it as an IP address or a host name) 33 | */ 34 | public function test_dsn_parsing_error() 35 | { 36 | new Server('deployer@'); 37 | } 38 | 39 | /** @dataProvider localDsnProvider */ 40 | public function test_local_dsn_parsing(string $dsn) 41 | { 42 | $server = new Server($dsn); 43 | 44 | $this->assertTrue($server->isLocalHost()); 45 | } 46 | 47 | /** @dataProvider sshConnectionStringProvider */ 48 | public function test_ssh_connection_string($dsn, $expectedSshConnectionString) 49 | { 50 | $server = new Server($dsn); 51 | 52 | $this->assertSame($expectedSshConnectionString, $server->getSshConnectionString()); 53 | } 54 | 55 | public function test_ssh_agent_forwarding() 56 | { 57 | $server = new Server('host'); 58 | $server->set(Property::use_ssh_agent_forwarding, true); 59 | 60 | $this->assertSame('ssh -A host', $server->getSshConnectionString()); 61 | } 62 | 63 | public function test_default_server_roles() 64 | { 65 | $server = new Server('host'); 66 | 67 | $this->assertSame([Server::ROLE_APP], $server->getRoles()); 68 | } 69 | 70 | /** @dataProvider serverRolesProvider */ 71 | public function test_server_roles(array $definedRoles, array $expectedRoles) 72 | { 73 | $server = new Server('host', $definedRoles); 74 | 75 | $this->assertSame($expectedRoles, $server->getRoles()); 76 | } 77 | 78 | public function test_default_server_properties() 79 | { 80 | $server = new Server('host'); 81 | 82 | $this->assertSame([], $server->getProperties()); 83 | } 84 | 85 | public function test_server_properties() 86 | { 87 | $properties = ['prop1' => -3.14, 'prop2' => false, 'prop3' => 'Lorem Ipsum', 'prop4' => ['foo' => 'bar']]; 88 | $server = new Server('host', [], $properties); 89 | 90 | $this->assertSame($properties, $server->getProperties()); 91 | } 92 | 93 | public function test_get_set_has_server_properties() 94 | { 95 | $properties = ['prop1' => -3.14, 'prop2' => false, 'prop3' => 'Lorem Ipsum', 'prop4' => ['foo' => 'bar']]; 96 | $server = new Server('host'); 97 | 98 | foreach ($properties as $name => $value) { 99 | $server->set($name, $value); 100 | } 101 | 102 | foreach ($properties as $name => $value) { 103 | $this->assertTrue($server->has($name)); 104 | $this->assertSame($value, $server->get($name)); 105 | } 106 | } 107 | 108 | /** @dataProvider expressionProvider */ 109 | public function test_resolve_properties(array $properties, string $expression, string $expectedExpression) 110 | { 111 | $server = new Server('host', [], $properties); 112 | 113 | $this->assertSame($expectedExpression, $server->resolveProperties($expression)); 114 | } 115 | 116 | /** 117 | * @dataProvider wrongExpressionProvider 118 | * @expectedException \InvalidArgumentException 119 | * @expectedExceptionMessageRegExp /The ".*" property in ".*" expression is not a valid server property./ 120 | */ 121 | public function test_resolve_unknown_properties(array $properties, string $expression) 122 | { 123 | $server = new Server('host', [], $properties); 124 | $server->resolveProperties($expression); 125 | } 126 | 127 | public function dsnProvider() 128 | { 129 | yield ['123.123.123.123', '123.123.123.123', null, null]; 130 | yield ['deployer@123.123.123.123', '123.123.123.123', 'deployer', null]; 131 | yield ['deployer@123.123.123.123:22001', '123.123.123.123', 'deployer', 22001]; 132 | 133 | yield ['example.com', 'example.com', null, null]; 134 | yield ['deployer@example.com', 'example.com', 'deployer', null]; 135 | yield ['deployer@example.com:22001', 'example.com', 'deployer', 22001]; 136 | 137 | yield ['host', 'host', null, null]; 138 | yield ['deployer@host', 'host', 'deployer', null]; 139 | yield ['deployer@host:22001', 'host', 'deployer', 22001]; 140 | 141 | yield ['ssh://deployer@123.123.123.123:22001', '123.123.123.123', 'deployer', 22001]; 142 | yield ['ssh://deployer@example.com:22001', 'example.com', 'deployer', 22001]; 143 | yield ['ssh://deployer@host:22001', 'host', 'deployer', 22001]; 144 | } 145 | 146 | public function localDsnProvider() 147 | { 148 | yield ['local']; 149 | yield ['deployer@local']; 150 | yield ['deployer@local:22001']; 151 | 152 | yield ['localhost']; 153 | yield ['deployer@localhost']; 154 | yield ['deployer@localhost:22001']; 155 | 156 | yield ['127.0.0.1']; 157 | yield ['deployer@127.0.0.1']; 158 | yield ['deployer@127.0.0.1:22001']; 159 | } 160 | 161 | public function serverRolesProvider() 162 | { 163 | yield [[], []]; 164 | yield [[Server::ROLE_APP], [Server::ROLE_APP]]; 165 | yield [['custom_role'], ['custom_role']]; 166 | yield [['custom_role_1', 'custom_role_2'], ['custom_role_1', 'custom_role_2']]; 167 | } 168 | 169 | public function sshConnectionStringProvider() 170 | { 171 | yield ['localhost', '']; 172 | yield ['123.123.123.123', 'ssh 123.123.123.123']; 173 | yield ['deployer@123.123.123.123', 'ssh deployer@123.123.123.123']; 174 | yield ['deployer@123.123.123.123:22001', 'ssh deployer@123.123.123.123 -p 22001']; 175 | } 176 | 177 | public function expressionProvider() 178 | { 179 | yield [['prop1' => 'aaa'], '{{ prop1 }}', 'aaa']; 180 | yield [['prop.1' => 'aaa'], '{{ prop.1 }}', 'aaa']; 181 | yield [['prop-1' => 'aaa'], '{{ prop-1 }}', 'aaa']; 182 | yield [['prop-1.2_3' => 'aaa'], '{{ prop-1.2_3 }}', 'aaa']; 183 | yield [['prop1' => 'aaa'], '{{ prop1 }}', 'aaa']; 184 | yield [['prop1' => 'aaa'], '{{ prop1 }}', 'aaa']; 185 | yield [['prop1' => 'aaa'], '{{ prop1 }}', 'aaa']; 186 | yield [['prop1' => 'aaa'], '{{ prop1', '{{ prop1']; 187 | yield [['prop1' => 'aaa', 'prop2' => 'bbb'], 'cd {{ prop1 }} && run {{ prop2 }}', 'cd aaa && run bbb']; 188 | yield [['prop1' => 'aaa', 'prop2' => 'bbb'], 'cd {{ prop1 }}{{ prop2 }}', 'cd aaabbb']; 189 | } 190 | 191 | public function wrongExpressionProvider() 192 | { 193 | yield [[], '{{ prop1 }}']; 194 | yield [['prop1' => 'aaa'], '{{ prop 1 }}']; 195 | yield [['prop1' => 'aaa'], '{{ prop2 }}']; 196 | yield [['prop1' => 'aaa'], 'cd {{ prop1 }} && run {{ prop2 }}']; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /tests/Task/TaskCompletedTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace EasyCorp\Bundle\EasyDeployBundle\Tests; 13 | 14 | use EasyCorp\Bundle\EasyDeployBundle\Server\Server; 15 | use EasyCorp\Bundle\EasyDeployBundle\Task\TaskCompleted; 16 | use PHPUnit\Framework\TestCase; 17 | 18 | class TaskCompletedTest extends TestCase 19 | { 20 | public function test_server() 21 | { 22 | $result = new TaskCompleted(new Server('deployer@host1'), 'aaa', 0); 23 | 24 | $this->assertSame('deployer', $result->getServer()->getUser()); 25 | $this->assertSame('host1', $result->getServer()->getHost()); 26 | } 27 | 28 | public function test_output() 29 | { 30 | $result = new TaskCompleted(new Server('localhost'), 'aaa ', 0); 31 | 32 | $this->assertSame('aaa ', $result->getOutput()); 33 | $this->assertSame('aaa', $result->getTrimmedOutput()); 34 | } 35 | 36 | public function test_exit_code() 37 | { 38 | $result = new TaskCompleted(new Server('localhost'), 'aaa', -1); 39 | 40 | $this->assertSame(-1, $result->getExitCode()); 41 | $this->assertFalse($result->isSuccessful()); 42 | } 43 | } 44 | --------------------------------------------------------------------------------