1: <?php
2:
3: /*****************************************************************************************
4: * X2Engine Open Source Edition is a customer relationship management program developed by
5: * X2Engine, Inc. Copyright (C) 2011-2016 X2Engine Inc.
6: *
7: * This program is free software; you can redistribute it and/or modify it under
8: * the terms of the GNU Affero General Public License version 3 as published by the
9: * Free Software Foundation with the addition of the following permission added
10: * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK
11: * IN WHICH THE COPYRIGHT IS OWNED BY X2ENGINE, X2ENGINE DISCLAIMS THE WARRANTY
12: * OF NON INFRINGEMENT OF THIRD PARTY RIGHTS.
13: *
14: * This program is distributed in the hope that it will be useful, but WITHOUT
15: * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16: * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
17: * details.
18: *
19: * You should have received a copy of the GNU Affero General Public License along with
20: * this program; if not, see http://www.gnu.org/licenses or write to the Free
21: * Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
22: * 02110-1301 USA.
23: *
24: * You can contact X2Engine, Inc. P.O. Box 66752, Scotts Valley,
25: * California 95067, USA. or at email address [email protected].
26: *
27: * The interactive user interfaces in modified source and object code versions
28: * of this program must display Appropriate Legal Notices, as required under
29: * Section 5 of the GNU Affero General Public License version 3.
30: *
31: * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
32: * these Appropriate Legal Notices must retain the display of the "Powered by
33: * X2Engine" logo. If the display of the logo is not reasonably feasible for
34: * technical reasons, the Appropriate Legal Notices must display the words
35: * "Powered by X2Engine".
36: *****************************************************************************************/
37:
38: Yii::import('application.commands.X2ConsoleCommand');
39:
40: /**
41: * Update/migrate custom code from the "custom" folder using Git.
42: *
43: * Notes:
44: * (1) This command requires a Unix-like shell environment with rsync and git
45: * installed in it in order to run properly.
46: * (2) The Git repository must be up-to-date and have tags corresponding to
47: * the versions updating to and from.
48: * (3) This script will not work properly if the git repository is a clone of
49: * the public repository found on Github, and if using Professional Edition.
50: * Otherwise, if using Open Source Edition, this script should work with a
51: * clone of that repository (assuming the clone has all version tags).
52: * (4) Since controller classes only extend their base-code analogues, and do
53: * not fully copy/replace them, they are ignored by this whole process.
54: * Updating them should just be a matter of updating only the methods that
55: * were overridden, if any, instead of the entire file.
56: *
57: * @property string $branch The name of the temporary branch that will be used
58: * for merging and updating custom code.
59: * @property array $fileList List of custom files to be copied.
60: * @property string $gitdir The directory of the git repository. If unspecified,
61: * it is assumed to be one level above the web root.
62: * @property string $rsync Default rsync command to use for synchronizing files.
63: * @property string $source The path to the custom folder. If unspecified, it is
64: * assumed that it is the custom folder inside the current installation.
65: * @package application.commands
66: * @author Demitri Morgan <[email protected]>
67: */
68: class MigrateCustomCommand extends X2ConsoleCommand {
69:
70: const DEBUG = 0;
71:
72: const PERSIST_FILE = '.x2_git_migrate.json';
73:
74: /**
75: * The version from which the custom code is being updated.
76: * @var string
77: */
78: public $origin;
79:
80: /**
81: * The version to which the custom code should be updated.
82: * @var string
83: */
84: public $target;
85:
86: private $_branch;
87: private $_fileList;
88: private $_gitdir;
89: private $_source;
90: /**
91: * Stores parameter names.
92: * @var array
93: */
94: private $params = array();
95:
96: /**
97: * Updates the custom code.
98: *
99: * If it can automatically merge, and if there are no merge conflicts, it
100: * will copy the files back into the source folder.
101: *
102: * @param string $source Path to the custom code folder.
103: * @param string $origin The version of the X2Engine installation at which it
104: * was customized. In other words, the version from which X2Engine is being
105: * updated.
106: * @param string $target The target version to which the X2Engine
107: * customizations will be updated.
108: * @param string $gitdir The directory
109: * @param string $branch Name of branch to use for merging upstream changes
110: * into the custom code
111: */
112: public function actionUpdate($origin,$target,$source=null,$gitdir=null,$branch=null,$nocopy=0) {
113: // Initialize
114: $this->initParams(get_defined_vars());
115:
116: // Prompt the user if there's a branch name collision
117: $delBranch = 'y';
118: if($this->branchExists()) {
119: $msg = "A branch named {$this->branch} will be created, but such a "
120: ."branch already exists in the Git repository. It will be "
121: ."deleted. Continue?";
122: $msg = $this->formatter($msg)->bold()->color('red')->format();
123: $delBranch = $this->prompt($msg,'y');
124: }
125: if(strtolower($delBranch)=='n') {
126: return;
127: }
128:
129: // Assume we're starting a new update, so clean everything up:
130: $this->cleanUp();
131:
132: // Create the new branch at the start tag:
133: $this->headerMsg("-- Creating a Git branch for the update at {$this->origin} --");
134: $mkBranch = $this->git("branch {$this->branch} {$this->origin}");
135: if($mkBranch != 0) {
136: $this->end();
137: }
138:
139: // Checkout:
140: $this->headerMsg('-- Switching to the new branch --');
141: $checkout = $this->git("checkout {$this->branch}");
142: if($checkout != 0) {
143: $this->end();
144: }
145:
146: // Copy files into the repo:
147: $this->copyForth();
148:
149: // Commit:
150: $this->headerMsg('-- Committing changes --');
151: $this->git("add ./");
152: $commit = $this->git("commit -a -m 'Local custom changes as of {$this->origin}'");
153: if($commit != 0) {
154: $this->end();
155: }
156:
157: // Update:
158: $this->headerMsg("-- Merging upstream changes to version {$this->target} --");
159: $update = $this->git("merge {$this->target}",false);
160: if($update != 0) {
161: $this->headerMsg("Automatic merge failed. Resolve conflicts, commit changes, and run \"migratecustom copy --source={$this->source}\"",'red');
162: $this->end();
163: }
164:
165: // Copy the merged files:
166: $this->copyBack();
167:
168: // Done.
169: $this->end(true);
170: }
171:
172: /**
173: * Copies the current source files onto analogues found in the git
174: * directory.
175: * @param string $source Path to the source (custom folder)
176: *
177: */
178: public function actionCopy($origin=null,$target=null,$source=null,$gitdir=null,$branch=null) {
179: if(!isset($source) || !isset($origin,$target) || !isset($branch)) {
180: // Use the persist file to restore data, in case of having to
181: // manually fix conflicts and merge, so that the process can be
182: // resumed
183: $this->restoreParams(get_defined_vars());
184: }
185: $this->copyBack();
186:
187: // Done.
188: $this->end(true);
189: }
190:
191: public function branchExists() {
192: return (int) $this->git("show-branch {$this->branch}",false) == 0;
193: }
194:
195: /**
196: * Removes the persist file and deletes the temporary branch.
197: */
198: public function cleanUp() {
199: // Delete persist file
200: if(file_exists($persistFile = $this->source.DIRECTORY_SEPARATOR.self::PERSIST_FILE)){
201: $this->headerMsg("-- Deleting the persist file --");
202: unlink($persistFile);
203: }
204: // Delete branch if it exists
205: if($this->branchExists()) {
206: $this->headerMsg("-- Deleting temporary branch {$this->branch} --");
207: $this->git('reset --hard HEAD');
208: $this->git('checkout -q master');
209: $this->git("branch -D {$this->branch}");
210: }
211: }
212:
213: /**
214: * Copies the custom code from the git repository back into the original
215: * folder, overwriting originals.
216: */
217: public function copyBack() {
218: $this->headerMsg('-- Copying merged files from the Git repository back into the source --');
219: $this->sys("{$this->rsync} --existing {$this->gitdir}/x2engine/ {$this->source}/");
220: }
221:
222: public function copyForth(){
223: $this->headerMsg('-- Copying customized files into the Git repository --');
224: $this->sys("{$this->rsync} {$this->source}/ {$this->gitdir}/x2engine/");
225: }
226:
227: /**
228: * Displays debugging messages
229: * @param type $msg
230: */
231: public function debug($msg) {
232: if(self::DEBUG) {
233: echo $this->formatter('[debug] ')->color('blue')->bold()->format()."$msg\n";
234: }
235: }
236:
237: /**
238: *
239: */
240: public function end($cleanUp = false) {
241: if($cleanUp){
242: $this->cleanUp();
243: }else{
244: $this->saveParams();
245: }
246: Yii::app()->end();
247: }
248:
249: /**
250: * Opens a git subprocess in the git directory.
251: *
252: * @param string $command Git command to run
253: * @param bool $echo Whether to echo (true) or suppress (false) any output
254: * from the command.
255: * @param bool $embolden Whether to embolden error output and turn it red.
256: */
257: public function git($command,$echo=true,$embolden = true){ // &$pipes,$descriptorSpec=array()) {
258: return $this->sys("git $command",$this->gitdir,$echo,$embolden);
259: }
260:
261: /**
262: * Run a system command, echo its output.
263: *
264: * @param type $command
265: * @param bool $echo Whether to echo (true) or suppress (false) any standard
266: * output from the command.
267: * @param bool $embolden Whether to embolden error output and turn it red.
268: * @return type
269: */
270: public function sys($command,$cwd=null,$echo=true,$embolden=true) {
271: if($cwd == null) {
272: $cwd = __DIR__;
273: }
274: $descriptorSpec = array(
275: 0 => array('pipe', 'r'), // stdin
276: 1 => array('pipe', 'w'), // stdout
277: 2 => array('pipe', 'w'), // stderr
278: );
279: $this->debug("Running: $command");
280: $cmd = proc_open("$command", $descriptorSpec, $pipes, $cwd);
281: $stdOut = stream_get_contents($pipes[1]);
282: $stdErr = stream_get_contents($pipes[2]);
283: $code = proc_close($cmd);
284: $this->debug("Exit code for $command: $code\n");
285: if($code != 0 && $echo) {
286: if($embolden) {
287: $this->headerMsg($stdErr,'red',false);
288: } else {
289: echo $stdErr;
290: }
291: } elseif($echo) {
292: echo $stdOut;
293: }
294: return $code;
295: }
296:
297: /**
298: * Gets the default source path, which is guaranteed to exist more or less
299: * @return string
300: */
301: public function getDefaultSource() {
302: return realpath(implode(DIRECTORY_SEPARATOR,array(Yii::app()->basePath,'..','custom')));
303: }
304:
305: /**
306: * Getter for {@branch}
307: * @return type
308: */
309: public function getBranch() {
310: if(empty($this->_branch)) {
311: $this->_branch = "custom_code_update_{$this->origin}-{$this->target}";
312: }
313: return $this->_branch;
314: }
315:
316: /**
317: * Getter for {@link fileList}
318: * @return array
319: */
320: public function getFileList() {
321: if(!isset($this->_fileList)) {
322: $cmd = new CommandUtil();
323: $findCmd = "find {$this->source}/ -type f";
324: $this->debug("Running find command: $findCmd");
325: $output = $cmd->run($findCmd)->output();
326: $this->debug("Output: $output");
327: $output = explode("\n",$output);
328: $this->_fileList = array();
329: foreach($output as $line) {
330: if(preg_match(':(?<path>protected.+\.php)$:',$line,$match)) {
331: $this->_fileList[] = $match['path'];
332: $this->debug("file in file list: ".$match['path']);
333: } else {
334: $this->debug("line not part of file list: $line");
335: }
336: }
337: }
338: return $this->_fileList;
339: }
340:
341: /**
342: * Getter for {@link gitdir}
343: * @return string
344: */
345: public function getGitdir() {
346:
347: if(empty($this->_gitdir)) {
348: $this->_gitdir = realpath(implode(DIRECTORY_SEPARATOR,array(Yii::app()->basePath,'..','..')));
349: }
350: return $this->_gitdir;
351: }
352:
353: /**
354: * Getter for {@link rsync}
355: * @return string
356: */
357: public function getRsync() {
358: return 'rsync -ac --exclude="*~"';
359: }
360:
361: /**
362: * Getter for {@link source}
363: * @return string
364: */
365: public function getSource() {
366: if(empty($this->_source)) {
367: $this->_source = $this->getDefaultSource();
368: }
369: return $this->_source;
370: }
371:
372: /**
373: * Sets properties initially
374: * @param array $params
375: */
376: public function initParams($params) {
377: foreach($params as $name=>$value) {
378: if($this->canSetProperty($name) || property_exists($this, $name)) {
379: if($name=='gitdir' || $name == 'source')
380: $value = rtrim($value,DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
381: $this->params[$name] = $name;
382: $this->$name = $value;
383: }
384: }
385: }
386:
387: /**
388: * Uses parameters saved to the persistence file during the current operation
389: *
390: * @param type $params Optional parameters to override old saved parameters.
391: */
392: public function restoreParams($params=array()) {
393: $persistFile = $this->source.DIRECTORY_SEPARATOR.self::PERSIST_FILE;
394: $savedParams = file_exists($persistFile) ? json_decode(file_get_contents($persistFile),1) : array();
395: $savedParams = empty($savedParams) ? array() : $savedParams;
396: foreach(array_keys($params) as $name) {
397: if(!empty($savedParams[$name]) && empty($params[$name])) {
398: $params[$name] = $savedParams[$name];
399: }
400: }
401: $this->initParams($params);
402: $this->headerMsg("-- Continuing with previous parameters --");
403: foreach($this->params as $property) {
404: echo $this->formatter($property)->color('green')->format().": {$this->$property}\n";
405: }
406: }
407:
408: /**
409: * Saves parameters to the persistence file.
410: */
411: public function saveParams() {
412: foreach($this->params as $property) {
413: $params[$property] = $this->$property;
414: }
415: file_put_contents($this->source.DIRECTORY_SEPARATOR.self::PERSIST_FILE,json_encode($params));
416: }
417:
418: /**
419: * Setter for {@link branch}
420: * @param string $value
421: */
422: public function setBranch($value){
423: $this->_branch = $value;
424: }
425:
426: /**
427: * Setter for {@link gitdir}
428: * @param string $value
429: */
430: public function setGitdir($value){
431: $this->_gitdir = $this->validPath($value,'gitdir');
432: }
433:
434: /**
435: * Setter for {@link source}
436: * @param type $value
437: */
438: public function setSource($value){
439: $this->_source = $this->validPath($value,'source');
440: }
441:
442: public function validPath($value,$name) {
443: $path = realpath(str_replace('~','/home/'.get_current_user(),$value));
444: if(!$path) {
445: $this->headerMsg("Invalid path specified for $name: $value",'red');
446: Yii::app()->end();
447: }
448: return $path;
449: }
450: }
451:
452: ?>
453: