├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── Git.php ├── Mercurial.php ├── SelfUpdateController.php ├── Shell.php ├── ShellResult.php ├── VersionControlSystem.php ├── VersionControlSystemInterface.php └── views └── selfUpdateConfig.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Yii 2 Project Self Update extension Change Log 2 | ============================================== 3 | 4 | 1.0.3, November 3, 2017 5 | ----------------------- 6 | 7 | - Bug: Usage of deprecated `yii\base\Object` changed to `yii\base\BaseObject` allowing compatibility with PHP 7.2 (klimov-paul) 8 | - Enh: Usage of deprecated exit code constants of `yii\console\Controller` changed to `yii\console\ExitCode` ones (klimov-paul) 9 | 10 | 11 | 1.0.2, December 8, 2016 12 | ----------------------- 13 | 14 | - Enh #5: Added `SelfUpdateController::$composerOptions` allowing setup of additional options for `composer install` command (klimov-paul) 15 | 16 | 17 | 1.0.1, November 24, 2016 18 | ------------------------ 19 | 20 | - Enh #4: Added `SelfUpdateController::$reportFrom` allowing setup of the report sender email address (klimov-paul) 21 | 22 | 23 | 1.0.0, February 11, 2016 24 | ------------------------ 25 | 26 | - Initial release. 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The Yii framework is free software. It is released under the terms of 2 | the following BSD License. 3 | 4 | Copyright © 2015 by Yii2tech (https://github.com/yii2tech) 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions 9 | are met: 10 | 11 | * Redistributions of source code must retain the above copyright 12 | notice, this list of conditions and the following disclaimer. 13 | * Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in 15 | the documentation and/or other materials provided with the 16 | distribution. 17 | * Neither the name of Yii2tech nor the names of its 18 | contributors may be used to endorse or promote products derived 19 | from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 24 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 25 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 26 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 27 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 28 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 29 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 30 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 31 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 32 | POSSIBILITY OF SUCH DAMAGE. 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

Project Self Update Extension for Yii 2

6 |
7 |

8 | 9 | This extension allows automatic project updating in case its source code is maintained via version control system, such 10 | as [GIT](https://git-scm.com/) or [Mercurial](https://mercurial.selenic.com/). Such update includes following steps: 11 | - check if there are any changes at VSC remote repository 12 | - link web server web directories to the stubs, while project update is running 13 | - apply remote VCS changes 14 | - update 'vendor' directory via Composer 15 | - clear application cache and temporary directories 16 | - perform additional actions, like applying database migrations 17 | - link web server web directories to the project web directories, once update is complete 18 | - notify developer(s) about update result via email 19 | 20 | > Note: this solution is very basic and may not suite for the complex project update workflow. You may consider 21 | usage of more sophisticated tools like [Phing](https://www.phing.info/). However, this extension may be used as a part 22 | of such solution. 23 | 24 | For license information check the [LICENSE](LICENSE.md)-file. 25 | 26 | [![Latest Stable Version](https://poser.pugx.org/yii2tech/selfupdate/v/stable.png)](https://packagist.org/packages/yii2tech/selfupdate) 27 | [![Total Downloads](https://poser.pugx.org/yii2tech/selfupdate/downloads.png)](https://packagist.org/packages/yii2tech/selfupdate) 28 | [![Build Status](https://travis-ci.org/yii2tech/selfupdate.svg?branch=master)](https://travis-ci.org/yii2tech/selfupdate) 29 | 30 | 31 | Requirements 32 | ------------ 33 | 34 | This extension requires Linux OS. 35 | 36 | 37 | Installation 38 | ------------ 39 | 40 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 41 | 42 | Either run 43 | 44 | ``` 45 | php composer.phar require --prefer-dist yii2tech/selfupdate 46 | ``` 47 | 48 | or add 49 | 50 | ```json 51 | "yii2tech/selfupdate": "*" 52 | ``` 53 | 54 | to the require section of your composer.json. 55 | 56 | 57 | Usage 58 | ----- 59 | 60 | This extension provides special console controller [[yii2tech\selfupdate\SelfUpdateController]], which allows automatic updating of 61 | the project, if its source code is maintained via version control system. 62 | In order to enable this controller in your project, you should add it to your console application `controllerMap` at 63 | configuration file: 64 | 65 | ```php 66 | return [ 67 | 'controllerMap' => [ 68 | 'self-update' => 'yii2tech\selfupdate\SelfUpdateController' 69 | ], 70 | // ... 71 | ]; 72 | ``` 73 | 74 | Now you should able to use 'self-update' command via console: 75 | 76 | ``` 77 | yii self-update 78 | ``` 79 | 80 | 81 | ## Project preparation 82 | 83 | In order to use 'self-update' command, you should perform several preparations in your project, allowing 84 | certain shell commands to be executed in non-interactive (without user prompt) mode. 85 | 86 | First of all, you should clone (checkout) your project from version control system and switch project working copy 87 | to the branch, which should be used at this particular server. Using GIT this actions can be performed via following commands: 88 | 89 | ``` 90 | cd /path/to/my/project 91 | git clone git@my-git-server.com/myproject.git 92 | git checkout production 93 | ``` 94 | 95 | > Attention: you need to configure your VCS (or at least your project working copy) in the way interacting with remote 96 | repository does NOT require user prompt, like input of username or password! This can be achieved using authentication 97 | keys or 'remember password' feature. 98 | 99 | Then you should make project operational performing all necessary actions for its initial deployment, like running 100 | 'composer install', creating necessary directories and so on. 101 | 102 | 103 | ## Using self-update command 104 | 105 | Once project is setup you need to create a configuration for its updating. This can be done using 'self-update/config' 106 | command: 107 | 108 | ``` 109 | yii self-update/config @app/config/self-update.php 110 | ``` 111 | 112 | This will generate configuration file, which should be manually adjusted depending on the particular project structure 113 | and server environment. For the common project such configuration file may look like following: 114 | 115 | ```php 116 | [ 121 | 'developer@domain.com', 122 | ], 123 | // Mailer component to be used 124 | 'mailer' => 'mailer', 125 | // Mutex component to be used 126 | 'mutex' => 'mutex', 127 | // path to project root directory (VCS root directory) 128 | 'projectRootPath' => '@app', 129 | // web path stubs configuration 130 | 'webPaths' => [ 131 | [ 132 | 'path' => '@app/web', 133 | 'link' => '@app/httpdocs', 134 | 'stub' => '@app/webstub', 135 | ], 136 | ], 137 | // cache components to be flushed 138 | 'cache' => [ 139 | 'cache' 140 | ], 141 | // temporary directories, which should be cleared after project update 142 | 'tmpDirectories' => [ 143 | '@app/web/assets', 144 | '@runtime/URI', 145 | '@runtime/HTML', 146 | '@runtime/debug', 147 | ], 148 | // list of shell commands, which should be executed after project update 149 | 'afterUpdateCommands' => [ 150 | 'php ' . escapeshellarg($_SERVER['SCRIPT_FILENAME']) . ' migrate/up --interactive=0', 151 | ], 152 | ]; 153 | ``` 154 | 155 | Please refer to [[\yii2tech\selfupdate\SelfUpdateController]] for particular option information. 156 | 157 | Once you have made all necessary adjustments at configuration file, you can run 'self-update/perform' command with it: 158 | 159 | ``` 160 | yii self-update @app/config/self-update.php 161 | ``` 162 | 163 | You may setup default configuration file name inside the `controllerMap` specification via [[yii2tech\selfupdate\SelfUpdateController::$configFile]]: 164 | 165 | ```php 166 | return [ 167 | 'controllerMap' => [ 168 | 'self-update' => [ 169 | 'class' => 'yii2tech\selfupdate\SelfUpdateController', 170 | 'configFile' => '@app/config/self-update.php', 171 | ] 172 | ], 173 | // ... 174 | ]; 175 | ``` 176 | 177 | Then invocation of the self-update command will be much more clear: 178 | 179 | ``` 180 | yii self-update 181 | ``` 182 | 183 | > Note: it is not necessary to create a separated configuration file: you can configure all necessary fields of 184 | [[yii2tech\selfupdate\SelfUpdateController]] inside `controllerMap` specification, but such approach is not recommended. 185 | 186 | 187 | Self Update Workflow 188 | -------------------- 189 | 190 | While running, [[yii2tech\selfupdate\SelfUpdateController]] performs following steps: 191 | 192 | - check if there are any changes at VSC remote repository 193 | - link web server web directories to the stubs, while project update is running 194 | - apply remote VCS changes 195 | - update 'vendor' directory via Composer 196 | - clear application cache and temporary directories 197 | - perform additional actions, like applying database migrations 198 | - link web server web directories to the project web directories, once update is complete 199 | - notify developer(s) about update result via email 200 | 201 | At the first stage there is a check for any changes in the remote repository. If there is no changes in remote 202 | repository for the current project VCS working copy branch, no further actions will be performed! 203 | 204 | If remote changes detected, the symbolic links pointing to the project '@web' directory will be switched to another 205 | directory, which should contain a 'stub' - some static HTML page, which says something like 'Application is under the 206 | maintenance, please check again later'. Although, usage of such stub it is up to you, it is recommended, because actual 207 | project update may take significant time before being complete. 208 | Project web directory will be linked back instead of stub, only after all update actions are performed. 209 | 210 | During update itself VCS remote changes are applied, `vendor` directory is updated via Composer, specified temporary 211 | directories will be cleared and cache flushed. 212 | 213 | > Note: in order for Composer be able to apply necessary changes, the 'composer.lock' file should be tracked by version 214 | control system! 215 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yii2tech/selfupdate", 3 | "description": "Basic extension for the Yii2 project self-update from VCS", 4 | "keywords": ["yii2", "selfupdate", "update", "deployment", "continuous", "integration", "VCS", "GIT", "Mercurial"], 5 | "type": "yii2-extension", 6 | "license": "BSD-3-Clause", 7 | "support": { 8 | "issues": "https://github.com/yii2tech/selfupdate/issues", 9 | "forum": "http://www.yiiframework.com/forum/", 10 | "wiki": "https://github.com/yii2tech/selfupdate/wiki", 11 | "source": "https://github.com/yii2tech/selfupdate" 12 | }, 13 | "authors": [ 14 | { 15 | "name": "Paul Klimov", 16 | "email": "klimov.paul@gmail.com" 17 | } 18 | ], 19 | "require": { 20 | "yiisoft/yii2": "~2.0.13" 21 | }, 22 | "repositories": [ 23 | { 24 | "type": "composer", 25 | "url": "https://asset-packagist.org" 26 | } 27 | ], 28 | "autoload": { 29 | "psr-4": {"yii2tech\\selfupdate\\": "src"} 30 | }, 31 | "extra": { 32 | "branch-alias": { 33 | "dev-master": "1.0.x-dev" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Git.php: -------------------------------------------------------------------------------- 1 | 18 | * @since 1.0 19 | */ 20 | class Git extends VersionControlSystem 21 | { 22 | /** 23 | * @var string path to the 'git' bin command. 24 | * By default simple 'git' is used assuming it available as global shell command. 25 | * It could be '/usr/bin/git' for example. 26 | */ 27 | public $binPath = 'git'; 28 | /** 29 | * @var string name of the GIT remote, which should be used to get changes. 30 | */ 31 | public $remoteName = 'origin'; 32 | 33 | 34 | /** 35 | * Returns currently active GIT branch name for the project. 36 | * @param string $projectRoot VCS project root directory path. 37 | * @return string branch name. 38 | * @throws Exception on failure. 39 | */ 40 | public function getCurrentBranch($projectRoot) 41 | { 42 | $result = Shell::execute('(cd {projectRoot}; {binPath} branch)', [ 43 | '{binPath}' => $this->binPath, 44 | '{projectRoot}' => $projectRoot, 45 | ]); 46 | foreach ($result->outputLines as $line) { 47 | if (($pos = stripos($line, '* ')) === 0) { 48 | return trim(substr($line, $pos + 2)); 49 | } 50 | } 51 | throw new Exception('Unable to detect current GIT branch: ' . $result->toString()); 52 | } 53 | 54 | /** 55 | * Checks, if there are some changes in remote repository. 56 | * @param string $projectRoot VCS project root directory path. 57 | * @param string $log if parameter passed it will be filled with related log string. 58 | * @return bool whether there are changes in remote repository. 59 | */ 60 | public function hasRemoteChanges($projectRoot, &$log = null) 61 | { 62 | $placeholders = [ 63 | '{binPath}' => $this->binPath, 64 | '{projectRoot}' => $projectRoot, 65 | '{remote}' => $this->remoteName, 66 | '{branch}' => $this->getCurrentBranch($projectRoot), 67 | ]; 68 | 69 | $fetchResult = Shell::execute('(cd {projectRoot}; {binPath} fetch {remote})', $placeholders); 70 | $log = $fetchResult->toString() . "\n"; 71 | 72 | $result = Shell::execute('(cd {projectRoot}; {binPath} diff --numstat HEAD {remote}/{branch})', $placeholders); 73 | $log .= $result->toString(); 74 | return ($result->isOk() && !$result->isOutputEmpty()); 75 | } 76 | 77 | /** 78 | * Applies changes from remote repository. 79 | * @param string $projectRoot VCS project root directory path. 80 | * @param string $log if parameter passed it will be filled with related log string. 81 | * @return bool whether the changes have been applied successfully. 82 | */ 83 | public function applyRemoteChanges($projectRoot, &$log = null) 84 | { 85 | $result = Shell::execute('(cd {projectRoot}; {binPath} merge {remote}/{branch})', [ 86 | '{binPath}' => $this->binPath, 87 | '{projectRoot}' => $projectRoot, 88 | '{remote}' => $this->remoteName, 89 | '{branch}' => $this->getCurrentBranch($projectRoot), 90 | ]); 91 | $log = $result->toString(); 92 | return $result->isOk(); 93 | } 94 | } -------------------------------------------------------------------------------- /src/Mercurial.php: -------------------------------------------------------------------------------- 1 | 16 | * @since 1.0 17 | */ 18 | class Mercurial extends VersionControlSystem 19 | { 20 | /** 21 | * @var string path to the 'hg' bin command. 22 | * By default simple 'hg' is used assuming it available as global shell command. 23 | * It could be '/usr/bin/hg' for example. 24 | */ 25 | public $binPath = 'hg'; 26 | 27 | 28 | /** 29 | * Returns currently active Mercurial branch name for the project. 30 | * @param string $projectRoot VCS project root directory path. 31 | * @return string branch name. 32 | */ 33 | public function getCurrentBranch($projectRoot) 34 | { 35 | $result = Shell::execute('(cd {projectRoot}; {binPath} branch)', [ 36 | '{binPath}' => $this->binPath, 37 | '{projectRoot}' => $projectRoot, 38 | ]); 39 | return $result->outputLines[0]; 40 | } 41 | 42 | /** 43 | * Checks, if there are some changes in remote repository. 44 | * @param string $projectRoot VCS project root directory path. 45 | * @param string $log if parameter passed it will be filled with related log string. 46 | * @return bool whether there are changes in remote repository. 47 | */ 48 | public function hasRemoteChanges($projectRoot, &$log = null) 49 | { 50 | $result = Shell::execute("(cd {projectRoot}; {binPath} incoming -b {branch} --newest-first --limit 1)", [ 51 | '{binPath}' => $this->binPath, 52 | '{projectRoot}' => $projectRoot, 53 | '{branch}' => $this->getCurrentBranch($projectRoot), 54 | ]); 55 | $log = $result->toString(); 56 | return $result->isOk(); 57 | } 58 | 59 | /** 60 | * Applies changes from remote repository. 61 | * @param string $projectRoot VCS project root directory path. 62 | * @param string $log if parameter passed it will be filled with related log string. 63 | * @return bool whether the changes have been applied successfully. 64 | */ 65 | public function applyRemoteChanges($projectRoot, &$log = null) 66 | { 67 | $result = Shell::execute('(cd {projectRoot}; {binPath} pull -b {branch} -u)', [ 68 | '{binPath}' => $this->binPath, 69 | '{projectRoot}' => $projectRoot, 70 | '{branch}' => $this->getCurrentBranch($projectRoot), 71 | ]); 72 | $log = $result->toString(); 73 | return $result->isOk(); 74 | } 75 | } -------------------------------------------------------------------------------- /src/SelfUpdateController.php: -------------------------------------------------------------------------------- 1 | 45 | * @since 1.0 46 | */ 47 | class SelfUpdateController extends Controller 48 | { 49 | /** 50 | * @var string controller default action ID. 51 | */ 52 | public $defaultAction = 'perform'; 53 | /** 54 | * @var array list of email addresses, which should be used to send execution reports. 55 | */ 56 | public $emails = []; 57 | /** 58 | * @var Mutex|array|string the mutex object or the application component ID of the mutex. 59 | * After the controller object is created, if you want to change this property, you should only assign it 60 | * with a mutex connection object. 61 | */ 62 | public $mutex = 'mutex'; 63 | /** 64 | * @var string path to project root directory, which means VCS root directory. Path aliases can be use here. 65 | */ 66 | public $projectRootPath = '@app'; 67 | /** 68 | * @var array project web path stubs configuration. 69 | * Each path configuration should have following keys: 70 | * 71 | * - 'path': string, path to web root folder 72 | * - 'link': string, path for the symbolic link, which should point to the web root 73 | * - 'stub': string, path to folder, which contains stub for the web 74 | * 75 | * Yii aliases can be used for all these keys. 76 | * For example: 77 | * 78 | * ```php 79 | * [ 80 | * [ 81 | * 'path' => '@app/web', 82 | * 'link' => '@app/httpdocs', 83 | * 'stub' => '@app/webstub', 84 | * ] 85 | * ] 86 | * ``` 87 | */ 88 | public $webPaths = []; 89 | /** 90 | * @var string|array list of cache application components, for which [[Cache::flush()]] method should be invoked. 91 | * Component ids, instances or array configurations can be used here. 92 | */ 93 | public $cache; 94 | /** 95 | * @var array list of temporary directories, which should be cleared after project update. 96 | * Path aliases can be used here. For example: 97 | * 98 | * ```php 99 | * [ 100 | * '@app/web/assets', 101 | * '@runtime/URI', 102 | * '@runtime/HTML', 103 | * '@runtime/debug', 104 | * ] 105 | * ``` 106 | */ 107 | public $tmpDirectories = []; 108 | /** 109 | * @var array list of commands, which should be executed before project update begins. 110 | * If command is a string it will be executed as shell command, otherwise as PHP callback. 111 | * For example: 112 | * 113 | * ```php 114 | * [ 115 | * 'mysqldump -h localhost -u root myproject > /path/to/backup/myproject.sql' 116 | * ], 117 | * ``` 118 | */ 119 | public $beforeUpdateCommands = []; 120 | /** 121 | * @var array list of shell commands, which should be executed after project update. 122 | * If command is a string it will be executed as shell command, otherwise as PHP callback. 123 | * For example: 124 | * 125 | * ```php 126 | * [ 127 | * 'php /path/to/project/yii migrate/up --interactive=0' 128 | * ], 129 | * ``` 130 | */ 131 | public $afterUpdateCommands = []; 132 | /** 133 | * @var array list of keywords, which presence in the shell command output is considered as 134 | * its execution error. 135 | */ 136 | public $shellResponseErrorKeywords = [ 137 | 'error', 138 | 'exception', 139 | 'ошибка', 140 | ]; 141 | /** 142 | * @var array list of possible version control systems (VCS) in format: `vcsFolderName => classConfig`. 143 | * VCS will be detected automatically based on which folder is available inside [[$projectRootPath]] 144 | */ 145 | public $versionControlSystems = [ 146 | '.git' => [ 147 | 'class' => 'yii2tech\selfupdate\Git' 148 | ], 149 | '.hg' => [ 150 | 'class' => 'yii2tech\selfupdate\Mercurial' 151 | ], 152 | ]; 153 | /** 154 | * @var array composer command options. 155 | * @see Shell::buildOptions() for valid syntax on specifying this value. 156 | * For example: 157 | * 158 | * ```php 159 | * [ 160 | * 'prefer-dist', 161 | * 'no-dev', 162 | * ] 163 | * ``` 164 | * 165 | * Note, that `no-interaction` option will be added automatically to the options list. 166 | * 167 | * @since 1.0.2 168 | */ 169 | public $composerOptions = []; 170 | /** 171 | * @var string path to the 'composer' bin command. 172 | * By default simple 'composer' is used, assuming it available as global shell command. 173 | * Path alias can be used here. For example: '@app/composer.phar'. 174 | */ 175 | public $composerBinPath = 'composer'; 176 | /** 177 | * @var array list of composer install root paths (the ones containing 'composer.json'). 178 | * Path aliases can be used here. 179 | */ 180 | public $composerRootPaths = [ 181 | '@app' 182 | ]; 183 | /** 184 | * @var \yii\mail\MailerInterface|array|string the mailer object or the application component ID of the mailer. 185 | * It will be used to send notification messages to [[$emails]]. 186 | * If not set or sending email via this component fails, the fallback to the plain PHP `mail()` function will be used instead. 187 | */ 188 | public $mailer; 189 | /** 190 | * @var string configuration file name. Settings from this file will be merged with the default ones. 191 | * Such configuration file can be created, using action 'config'. 192 | * Path alias can be used here, for example: '@app/config/self-update.php'. 193 | */ 194 | public $configFile; 195 | 196 | /** 197 | * @var array list of log entries. 198 | * @see log() 199 | */ 200 | private $logLines = []; 201 | /** 202 | * @var string name of the host, which will be used in reports. 203 | */ 204 | private $_hostName; 205 | /** 206 | * @var string email address, which should be used to send report email messages. 207 | */ 208 | private $_reportFrom; 209 | 210 | 211 | /** 212 | * Performs project update from VCS. 213 | * @param string|null $configFile the path or alias of the configuration file. 214 | * You may use the "config" command to generate 215 | * this file and then customize it for your needs. 216 | * @throws Exception on failure 217 | * @return int CLI exit code 218 | */ 219 | public function actionPerform($configFile = null) 220 | { 221 | if (empty($configFile)) { 222 | $configFile = $this->configFile; 223 | } 224 | if (!empty($configFile)) { 225 | $configFile = Yii::getAlias($configFile); 226 | if (!is_file($configFile)) { 227 | throw new Exception("The configuration file does not exist: $configFile"); 228 | } 229 | $this->log("Reading configuration from: $configFile"); 230 | Yii::configure($this, require $configFile); 231 | } 232 | 233 | if (!$this->acquireMutex()) { 234 | $this->stderr("Execution terminated: command is already running.\n", Console::FG_RED); 235 | return ExitCode::UNSPECIFIED_ERROR; 236 | } 237 | 238 | try { 239 | $this->normalizeWebPaths(); 240 | 241 | $projectRootPath = Yii::getAlias($this->projectRootPath); 242 | 243 | $versionControlSystem = $this->detectVersionControlSystem($projectRootPath); 244 | 245 | $changesDetected = $versionControlSystem->hasRemoteChanges($projectRootPath, $log); 246 | $this->log($log); 247 | 248 | if ($changesDetected) { 249 | $this->linkWebStubs(); 250 | 251 | $this->executeCommands($this->beforeUpdateCommands); 252 | 253 | $versionControlSystem->applyRemoteChanges($projectRootPath, $log); 254 | $this->log($log); 255 | 256 | $this->updateVendor(); 257 | $this->flushCache(); 258 | $this->clearTmpDirectories(); 259 | 260 | $this->executeCommands($this->afterUpdateCommands); 261 | 262 | $this->linkWebPaths(); 263 | 264 | $this->reportSuccess(); 265 | } else { 266 | $this->log('No changes detected. Project is already up-to-date.'); 267 | } 268 | 269 | } catch (\Exception $exception) { 270 | $this->log($exception->getMessage()); 271 | $this->reportFail(); 272 | 273 | $this->releaseMutex(); 274 | return ExitCode::UNSPECIFIED_ERROR; 275 | } 276 | 277 | $this->releaseMutex(); 278 | return ExitCode::OK; 279 | } 280 | 281 | /** 282 | * Creates a configuration file for the "perform" command. 283 | * 284 | * The generated configuration file contains detailed instructions on 285 | * how to customize it to fit for your needs. After customization, 286 | * you may use this configuration file with the "perform" command. 287 | * 288 | * @param string $fileName output file name or alias. 289 | * @return int CLI exit code. 290 | */ 291 | public function actionConfig($fileName) 292 | { 293 | $fileName = Yii::getAlias($fileName); 294 | if (file_exists($fileName)) { 295 | if (!$this->confirm("File '{$fileName}' already exists. Do you wish to overwrite it?")) { 296 | return ExitCode::OK; 297 | } 298 | } 299 | copy(Yii::getAlias('@yii2tech/selfupdate/views/selfUpdateConfig.php'), $fileName); 300 | $this->stdout("Configuration file template created at '{$fileName}' . \n\n", Console::FG_GREEN); 301 | return ExitCode::OK; 302 | } 303 | 304 | /** 305 | * Acquires current action lock. 306 | * @return bool lock acquiring result. 307 | */ 308 | protected function acquireMutex() 309 | { 310 | $this->mutex = Instance::ensure($this->mutex, Mutex::className()); 311 | return $this->mutex->acquire($this->composeMutexName()); 312 | } 313 | 314 | /** 315 | * Release current action lock. 316 | * @return bool lock release result. 317 | */ 318 | protected function releaseMutex() 319 | { 320 | return $this->mutex->release($this->composeMutexName()); 321 | } 322 | 323 | /** 324 | * Composes the mutex name. 325 | * @return string mutex name. 326 | */ 327 | protected function composeMutexName() 328 | { 329 | return get_class($this) . '::' . $this->action->getUniqueId(); 330 | } 331 | 332 | /** 333 | * Links web roots to the stub directories. 334 | * @see webPaths 335 | */ 336 | protected function linkWebStubs() 337 | { 338 | foreach ($this->webPaths as $webPath) { 339 | if (is_link($webPath['link'])) { 340 | unlink($webPath['link']); 341 | } 342 | symlink($webPath['stub'], $webPath['link']); 343 | } 344 | } 345 | 346 | /** 347 | * Links web roots to the actual web directories. 348 | * @see webPaths 349 | */ 350 | protected function linkWebPaths() 351 | { 352 | foreach ($this->webPaths as $webPath) { 353 | if (is_link($webPath['link'])) { 354 | unlink($webPath['link']); 355 | } 356 | symlink($webPath['path'], $webPath['link']); 357 | } 358 | } 359 | 360 | /** 361 | * Normalizes [[$webPaths]] value. 362 | * @throws InvalidConfigException on invalid configuration. 363 | */ 364 | protected function normalizeWebPaths() 365 | { 366 | $rawWebPaths = $this->webPaths; 367 | $webPaths = []; 368 | foreach ($rawWebPaths as $rawWebPath) { 369 | if (!isset($rawWebPath['path'], $rawWebPath['link'], $rawWebPath['stub'])) { 370 | throw new InvalidConfigException("Web path configuration should contain keys: 'path', 'link', 'stub'"); 371 | } 372 | $webPath = [ 373 | 'path' => Yii::getAlias($rawWebPath['path']), 374 | 'link' => Yii::getAlias($rawWebPath['link']), 375 | 'stub' => Yii::getAlias($rawWebPath['stub']), 376 | ]; 377 | if (!is_dir($webPath['path'])) { 378 | throw new InvalidConfigException("'{$webPath['path']}' ('{$rawWebPath['path']}') is not a directory."); 379 | } 380 | if (!is_dir($webPath['stub'])) { 381 | throw new InvalidConfigException("'{$webPath['stub']}' ('{$rawWebPath['stub']}') is not a directory."); 382 | } 383 | if (!is_link($webPath['link'])) { 384 | throw new InvalidConfigException("'{$webPath['link']}' ('{$rawWebPath['link']}') is not a symbolic link."); 385 | } 386 | if (!in_array(readlink($webPath['link']), [$webPath['path'], $webPath['stub']])) { 387 | throw new InvalidConfigException("'{$webPath['link']}' ('{$rawWebPath['link']}') does not pointing to actual web or stub directory."); 388 | } 389 | $webPaths[] = $webPath; 390 | } 391 | $this->webPaths = $webPaths; 392 | } 393 | 394 | /** 395 | * Flushes cache for all components specified at [[$cache]]. 396 | */ 397 | protected function flushCache() 398 | { 399 | if (!empty($this->cache)) { 400 | foreach ((array)$this->cache as $cache) { 401 | $cache = Instance::ensure($cache, Cache::className()); 402 | $cache->flush(); 403 | } 404 | $this->log('Cache flushed.'); 405 | } 406 | } 407 | 408 | /** 409 | * Clears all directories specified via [[$tmpDirectories]]. 410 | */ 411 | protected function clearTmpDirectories() 412 | { 413 | foreach ($this->tmpDirectories as $path) { 414 | $realPath = Yii::getAlias($path); 415 | $this->clearDirectory($realPath); 416 | $this->log("Directory '{$realPath}' cleared."); 417 | } 418 | } 419 | 420 | /** 421 | * Clears specified directory. 422 | * @param string $dir directory to be cleared. 423 | */ 424 | protected function clearDirectory($dir) 425 | { 426 | if (!is_dir($dir)) { 427 | return; 428 | } 429 | if (!($handle = opendir($dir))) { 430 | return; 431 | } 432 | $specialFileNames = [ 433 | '.htaccess', 434 | '.gitignore', 435 | '.gitkeep', 436 | '.hgignore', 437 | '.hgkeep', 438 | ]; 439 | while (($file = readdir($handle)) !== false) { 440 | if ($file === '.' || $file === '..') { 441 | continue; 442 | } 443 | if (in_array($file, $specialFileNames)) { 444 | continue; 445 | } 446 | $path = $dir . DIRECTORY_SEPARATOR . $file; 447 | if (is_dir($path)) { 448 | FileHelper::removeDirectory($path); 449 | } else { 450 | unlink($path); 451 | } 452 | } 453 | closedir($handle); 454 | } 455 | 456 | /** 457 | * Performs vendors update via Composer at all [[$composerRootPaths]]. 458 | */ 459 | protected function updateVendor() 460 | { 461 | $options = Shell::buildOptions(array_merge($this->composerOptions, ['no-interaction'])); 462 | foreach ($this->composerRootPaths as $path) { 463 | $this->execShellCommand('(cd {composerRoot}; {composer} install ' . $options . ')', [ 464 | '{composerRoot}' => Yii::getAlias($path), 465 | '{composer}' => Yii::getAlias($this->composerBinPath), 466 | ]); 467 | } 468 | } 469 | 470 | /** 471 | * Detects version control system used for the project. 472 | * @param string $path project root path. 473 | * @return VersionControlSystemInterface version control system instance. 474 | * @throws InvalidConfigException on failure. 475 | */ 476 | protected function detectVersionControlSystem($path) 477 | { 478 | foreach ($this->versionControlSystems as $folderName => $config) { 479 | if (file_exists($path . DIRECTORY_SEPARATOR . $folderName)) { 480 | return Yii::createObject($config); 481 | } 482 | } 483 | throw new InvalidConfigException("Unable to detect version control system: neither of '" . implode(', ', array_keys($this->versionControlSystems)) . "' is present under '{$path}'."); 484 | } 485 | 486 | /** 487 | * @param string $hostName server hostname. 488 | */ 489 | public function setHostName($hostName) 490 | { 491 | $this->_hostName = $hostName; 492 | } 493 | 494 | /** 495 | * @return string server hostname. 496 | */ 497 | public function getHostName() 498 | { 499 | if ($this->_hostName === null) { 500 | $hostName = @exec('hostname'); 501 | if (empty($hostName)) { 502 | $this->_hostName = Inflector::slug(Yii::$app->name) . '.com'; 503 | } else { 504 | $this->_hostName = $hostName; 505 | } 506 | } 507 | return $this->_hostName; 508 | } 509 | 510 | /** 511 | * @return string email address, which should be used to send report email messages. 512 | */ 513 | public function getReportFrom() 514 | { 515 | if ($this->_reportFrom === null) { 516 | $userName = @exec('whoami'); 517 | if (empty($userName)) { 518 | $userName = Inflector::slug(Yii::$app->name); 519 | } 520 | $hostName = $this->getHostName(); 521 | $this->_reportFrom = $userName . '@' . $hostName; 522 | } 523 | return $this->_reportFrom; 524 | } 525 | 526 | /** 527 | * @param string $reportFrom email address, which should be used to send report email messages. 528 | */ 529 | public function setReportFrom($reportFrom) 530 | { 531 | $this->_reportFrom = $reportFrom; 532 | } 533 | 534 | /** 535 | * @return string current date string. 536 | */ 537 | public function getCurrentDate() 538 | { 539 | return date('Y-m-d H:i:s'); 540 | } 541 | 542 | /** 543 | * Logs the message 544 | * @param string $message log message. 545 | */ 546 | protected function log($message) 547 | { 548 | $this->logLines[] = $message; 549 | $this->stdout($message . "\n\n"); 550 | } 551 | 552 | /** 553 | * Flushes log lines, returning them. 554 | * @return array log lines. 555 | */ 556 | protected function flushLog() 557 | { 558 | $logLines = $this->logLines; 559 | $this->logLines = []; 560 | return $logLines; 561 | } 562 | 563 | /** 564 | * Executes list of given commands. 565 | * @param array $commands commands to be executed. 566 | * @throws InvalidConfigException on invalid commands specification. 567 | */ 568 | protected function executeCommands(array $commands) 569 | { 570 | foreach ($commands as $command) { 571 | if (is_string($command)) { 572 | $this->execShellCommand($command); 573 | } elseif (is_callable($command)) { 574 | $this->log(call_user_func($command)); 575 | } else { 576 | throw new InvalidConfigException('Command should be a string or a valid PHP callback'); 577 | } 578 | } 579 | } 580 | 581 | /** 582 | * Executes shell command. 583 | * @param string $command command text. 584 | * @return string command output. 585 | * @param array $placeholders placeholders to be replaced using `escapeshellarg()` in format: `placeholder => value`. 586 | * @throws Exception on failure. 587 | */ 588 | protected function execShellCommand($command, array $placeholders = []) 589 | { 590 | $result = Shell::execute($command, $placeholders); 591 | $this->log($result->toString()); 592 | 593 | $output = $result->getOutput(); 594 | if (!$result->isOk()) { 595 | throw new Exception("Execution of '{$result->command}' failed: exit code = '{$result->exitCode}': \nOutput: \n{$output}"); 596 | } 597 | foreach ($this->shellResponseErrorKeywords as $errorKeyword) { 598 | if (stripos($output, $errorKeyword) !== false) { 599 | throw new Exception("Execution of '{$result->command}' failed! \nOutput: \n{$output}"); 600 | } 601 | } 602 | return $output; 603 | } 604 | 605 | /** 606 | * Sends report about success. 607 | */ 608 | protected function reportSuccess() 609 | { 610 | $this->reportResult('Update success'); 611 | } 612 | 613 | /** 614 | * Sends report about failure. 615 | */ 616 | protected function reportFail() 617 | { 618 | $this->reportResult('UPDATE FAILED'); 619 | } 620 | 621 | /** 622 | * Sends execution report. 623 | * Report message content will be composed from log messages. 624 | * @param string $subjectPrefix report message subject. 625 | */ 626 | protected function reportResult($subjectPrefix) 627 | { 628 | $emails = $this->emails; 629 | if (!empty($emails)) { 630 | $hostName = $this->getHostName(); 631 | $from = $this->getReportFrom(); 632 | $subject = $subjectPrefix . ': ' . $hostName . ' at ' . $this->getCurrentDate(); 633 | $message = implode("\n", $this->flushLog()); 634 | foreach ($emails as $email) { 635 | $this->sendEmail($from, $email, $subject, $message); 636 | } 637 | } 638 | } 639 | 640 | /** 641 | * Sends an email. 642 | * @param string $from sender email address 643 | * @param string $email single email address 644 | * @param string $subject email subject 645 | * @param string $message email content 646 | * @return bool success. 647 | */ 648 | protected function sendEmail($from, $email, $subject, $message) 649 | { 650 | if ($this->mailer === null) { 651 | return $this->sendEmailFallback($from, $email, $subject, $message); 652 | } 653 | 654 | try { 655 | /* @var $mailer \yii\mail\MailerInterface|BaseMailer */ 656 | $mailer = Instance::ensure($this->mailer, 'yii\mail\MailerInterface'); 657 | if ($mailer instanceof BaseMailer) { 658 | $mailer->useFileTransport = false; // ensure mailer is not in test mode 659 | } 660 | return $mailer->compose() 661 | ->setFrom($from) 662 | ->setTo($email) 663 | ->setSubject($subject) 664 | ->setTextBody($message) 665 | ->send(); 666 | } catch (\Exception $exception) { 667 | $this->log($exception->getMessage()); 668 | return $this->sendEmailFallback($from, $email, $subject, $message); 669 | } 670 | } 671 | 672 | /** 673 | * Sends an email via plain PHP `mail()` function. 674 | * @param string $from sender email address 675 | * @param string $email single email address 676 | * @param string $subject email subject 677 | * @param string $message email content 678 | * @return bool success. 679 | */ 680 | protected function sendEmailFallback($from, $email, $subject, $message) 681 | { 682 | $headers = [ 683 | "MIME-Version: 1.0", 684 | "Content-Type: text/plain; charset=UTF-8", 685 | ]; 686 | $subject = '=?UTF-8?B?' . base64_encode($subject) . '?='; 687 | 688 | $matches = []; 689 | preg_match_all('/([^<]*)<([^>]*)>/iu', $from, $matches); 690 | if (isset($matches[1][0],$matches[2][0])) { 691 | $name = '=?UTF-8?B?' . base64_encode(trim($matches[1][0])) . '?='; 692 | $from = trim($matches[2][0]); 693 | $headers[] = "From: {$name} <{$from}>"; 694 | } else { 695 | $headers[] = "From: {$from}"; 696 | } 697 | $headers[] = "Reply-To: {$from}"; 698 | 699 | return mail($email, $subject, $message, implode("\n", $headers)); 700 | } 701 | } -------------------------------------------------------------------------------- /src/Shell.php: -------------------------------------------------------------------------------- 1 | 16 | * @since 1.0 17 | */ 18 | class Shell 19 | { 20 | /** 21 | * Executes shell command. 22 | * @param string $command command to be executed. 23 | * @param array $placeholders placeholders to be replaced using `escapeshellarg()` in format: placeholder => value. 24 | * @return ShellResult execution result. 25 | */ 26 | public static function execute($command, array $placeholders = []) 27 | { 28 | if (!empty($placeholders)) { 29 | $command = strtr($command, array_map('escapeshellarg', $placeholders)); 30 | } 31 | $result = new ShellResult(); 32 | $result->command = $command; 33 | exec($command . ' 2>&1', $result->outputLines, $result->exitCode); 34 | return $result; 35 | } 36 | 37 | /** 38 | * Builds shell command options string from array. 39 | * Option, which does not use any value should be specified as an array value, option with value should 40 | * be specified as key-value pair, where key is an option name and value - option value. 41 | * Option name will be automatically prefixed with `--` in case it has not already. 42 | * 43 | * For example: 44 | * 45 | * ```php 46 | * [ 47 | * 'verbose', 48 | * 'username' => 'root' 49 | * ] 50 | * ``` 51 | * 52 | * @param array $options options specification. 53 | * @return string options string. 54 | * @since 1.0.2 55 | */ 56 | public static function buildOptions(array $options) 57 | { 58 | $parts = []; 59 | foreach ($options as $key => $value) { 60 | if (is_int($key)) { 61 | $parts[] = self::normalizeOptionName($value); 62 | } else { 63 | $parts[] = self::normalizeOptionName($key) . '=' . escapeshellarg($value); 64 | } 65 | } 66 | return implode(' ', $parts); 67 | } 68 | 69 | /** 70 | * Normalizes shell command option name, adding leading `-` if necessary. 71 | * @param string $name raw option name. 72 | * @return string normalized option name. 73 | * @since 1.0.2 74 | */ 75 | private static function normalizeOptionName($name) 76 | { 77 | if (strpos($name, '-') !== 0) { 78 | return '--' . $name; 79 | } 80 | return $name; 81 | } 82 | } -------------------------------------------------------------------------------- /src/ShellResult.php: -------------------------------------------------------------------------------- 1 | 19 | * @since 1.0 20 | */ 21 | class ShellResult extends BaseObject 22 | { 23 | /** 24 | * @var string command being executed. 25 | */ 26 | public $command; 27 | /** 28 | * @var int shell command execution exit code 29 | */ 30 | public $exitCode; 31 | /** 32 | * @var array shell command output lines. 33 | */ 34 | public $outputLines = []; 35 | 36 | 37 | /** 38 | * @param string $glue lines glue. 39 | * @return string shell command output. 40 | */ 41 | public function getOutput($glue = "\n") 42 | { 43 | return implode($glue, $this->outputLines); 44 | } 45 | 46 | /** 47 | * @return bool whether exit code is OK. 48 | */ 49 | public function isOk() 50 | { 51 | return $this->exitCode === 0; 52 | } 53 | 54 | /** 55 | * @return bool whether command execution produced empty output. 56 | */ 57 | public function isOutputEmpty() 58 | { 59 | return empty($this->outputLines); 60 | } 61 | 62 | /** 63 | * Checks if output contains given string 64 | * @param string $string needle string. 65 | * @return bool whether output contains given string. 66 | */ 67 | public function isOutputContains($string) 68 | { 69 | return stripos($this->getOutput(), $string) !== false; 70 | } 71 | 72 | /** 73 | * Checks if output matches give regular expression. 74 | * @param string $pattern regular expression 75 | * @return bool whether output matches given regular expression. 76 | */ 77 | public function isOutputMatches($pattern) 78 | { 79 | return preg_match($pattern, $this->getOutput()) > 0; 80 | } 81 | 82 | /** 83 | * @return string string representation of this object. 84 | */ 85 | public function toString() 86 | { 87 | return $this->command . "\n" . $this->getOutput() . "\n" . 'Exit code: ' . $this->exitCode; 88 | } 89 | 90 | /** 91 | * PHP magic method that returns the string representation of this object. 92 | * @return string the string representation of this object. 93 | */ 94 | public function __toString() 95 | { 96 | // __toString cannot throw exception 97 | // use trigger_error to bypass this limitation 98 | try { 99 | return $this->toString(); 100 | } catch (\Exception $e) { 101 | ErrorHandler::convertExceptionToError($e); 102 | return ''; 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /src/VersionControlSystem.php: -------------------------------------------------------------------------------- 1 | 16 | * @since 1.0 17 | */ 18 | abstract class VersionControlSystem extends BaseObject implements VersionControlSystemInterface 19 | { 20 | } -------------------------------------------------------------------------------- /src/VersionControlSystemInterface.php: -------------------------------------------------------------------------------- 1 | 15 | * @since 1.0 16 | */ 17 | interface VersionControlSystemInterface 18 | { 19 | /** 20 | * Checks, if there are some changes in remote repository. 21 | * @param string $projectRoot VCS project root directory path. 22 | * @param string $log if parameter passed it will be filled with related log string. 23 | * @return bool whether there are changes in remote repository. 24 | */ 25 | public function hasRemoteChanges($projectRoot, &$log = null); 26 | 27 | /** 28 | * Applies changes from remote repository. 29 | * @param string $projectRoot VCS project root directory path. 30 | * @param string $log if parameter passed it will be filled with related log string. 31 | * @return bool whether the changes have been applied successfully. 32 | */ 33 | public function applyRemoteChanges($projectRoot, &$log = null); 34 | } -------------------------------------------------------------------------------- /src/views/selfUpdateConfig.php: -------------------------------------------------------------------------------- 1 | [ 13 | //'developer@domain.com', 14 | ], 15 | // Mailer component to be used 16 | 'mailer' => 'mailer', 17 | // Mutex component to be used 18 | 'mutex' => 'mutex', 19 | // path to project root directory (VCS root directory) 20 | 'projectRootPath' => '@app', 21 | // web path stubs configuration 22 | 'webPaths' => [ 23 | [ 24 | 'path' => '@app/web', 25 | 'link' => '@app/httpdocs', 26 | 'stub' => '@app/webstub', 27 | ], 28 | ], 29 | // cache components to be flushed 30 | 'cache' => [ 31 | 'cache' 32 | ], 33 | // temporary directories, which should be cleared after project update 34 | 'tmpDirectories' => [ 35 | '@app/web/assets', 36 | '@runtime/URI', 37 | '@runtime/HTML', 38 | '@runtime/debug', 39 | ], 40 | // list of commands, which should be executed before project update begins 41 | 'beforeUpdateCommands' => [], 42 | // list of shell commands, which should be executed after project update 43 | 'afterUpdateCommands' => [ 44 | 'php ' . escapeshellarg($_SERVER['SCRIPT_FILENAME']) . ' migrate/up --interactive=0', 45 | ], 46 | // adjust Composer settings, if necessary : 47 | 'composerOptions' => YII_ENV === 'dev' ? [] : ['no-dev'], 48 | /*'composerBinPath' => 'composer', 49 | 'composerRootPaths' => [ 50 | '@app' 51 | ],*/ 52 | // adjust report settings, if necessary : 53 | //'hostName' => 'myproject.com', 54 | //'reportFrom' => 'root@myproject.com', 55 | ]; 56 | --------------------------------------------------------------------------------