_event = new kEvent('adm:OnDummy'); $this->isCommandLine = isset($GLOBALS['argv']) && count($GLOBALS['argv']); if ( !$this->isCommandLine ) { $this->ip = $this->Application->getClientIp(); } else { if ( isset($GLOBALS['argv'][3]) ) { $this->ip = $GLOBALS['argv'][3]; } if ( isset($GLOBALS['argv'][4]) ) { $new_stages = explode(',', $GLOBALS['argv'][4]); $unknown_stages = array_diff($new_stages, $this->stages); if ( $unknown_stages ) { throw new InvalidArgumentException('Unknown deployment stages: ' . implode(', ', $unknown_stages)); } $this->stages = $new_stages; } } } /** * Sets event, associated with deployment. * * @param kEvent $event Event. * * @return void */ public function setEvent(kEvent $event) { $this->_event = $event; } /** * Adds message to script execution log. * * @param string $message Message. * @param boolean $new_line Jump to next line. * * @return string */ private function toLog($message, $new_line = true) { if ( $new_line ) { $message .= PHP_EOL; } $this->logData['Output'] .= $message; return $message; } /** * Loads already applied revisions list of current module. * * @return self */ private function loadAppliedRevisions() { $sql = 'SELECT RevisionNumber FROM ' . TABLE_PREFIX . 'ModuleDeploymentLog WHERE Module = ' . $this->Conn->qstr($this->moduleName); $this->appliedRevisions = array_flip($this->Conn->GetCol($sql)); return $this; } /** * Deploys changes from all installed modules. * * @param boolean $dry_run Use dry run mode? * * @return boolean */ public function deployAll($dry_run = false) { if ( !$this->isCommandLine ) { echo '
' . PHP_EOL;
		}

		$ret = true;
		$this->dryRun = $dry_run;

		if ( in_array(self::STAGE_DB_MIGRATE, $this->stages) ) {
			foreach ( $this->Application->ModuleInfo as $module_name => $module_info ) {
				$this->moduleName = $module_name;

				if ( !file_exists($this->getModuleFile('project_upgrades.sql')) ) {
					continue;
				}

				$ret = $ret && $this->deploy($module_name);
			}
		}

		if ( in_array(self::STAGE_CACHE_RESET, $this->stages) ) {
			if ( $ret && !$this->dryRun ) {
				$this->resetCaches();
				$this->refreshThemes();
				$this->dumpAssets();
			}
		}

		if ( !$this->isCommandLine ) {
			echo kUtil::escape($this->_runShellScript());
			echo '
' . PHP_EOL; } return $ret; } /** * Runs user-specific shell script when deployment happens from Web. * * @return string */ protected function _runShellScript() { if ( !$this->Application->isDebugMode(false) ) { return ''; } $wrapper_script = '/usr/local/bin/guest2host_server.sh'; $script_name = FULL_PATH . '/tools/' . ($this->dryRun ? 'synchronize.sh' : 'deploy.sh'); if ( file_exists($wrapper_script) && file_exists($script_name) ) { $script_name = preg_replace('/^.*\/web/', constant('DBG_LOCAL_BASE_PATH'), $script_name); return shell_exec($wrapper_script . ' ' . $script_name . ' 2>&1'); } return ''; } /** * Deploys pending changes to a site. * * @param string $module_name Module name. * * @return boolean */ private function deploy($module_name) { echo $this->colorText('Deploying Module "' . $module_name . '":', 'cyan', true) . PHP_EOL; if ( !$this->upgradeDatabase() ) { return false; } try { if ( $this->dryRun ) { $this->exportLanguagePack(); } else { $this->importLanguagePack(); } } catch ( Exception $e ) { echo $this->colorText('Failed with Module "' . $module_name . '".', 'red', true) . PHP_EOL . PHP_EOL; return false; } echo $this->colorText('Done with Module "' . $module_name . '".', 'green', true) . PHP_EOL . PHP_EOL; return true; } /** * Import latest language pack (without overwrite). * * @return self */ private function importLanguagePack() { /** @var LanguageImportHelper $language_import_helper */ $language_import_helper = $this->Application->recallObject('LanguageImportHelper'); $this->out('Importing LanguagePack ... '); $filename = $this->getModuleFile('english.lang'); $language_import_helper->performImport($filename, '|0|1|2|', $this->moduleName); $this->displayStatus('OK'); return $this; } /** * Exports latest language pack. * * @return self */ private function exportLanguagePack() { static $languages = null; if ( !isset($languages) ) { $sql = 'SELECT LanguageId FROM ' . $this->Application->getUnitConfig('lang')->getTableName() . ' WHERE Enabled = 1'; $languages = $this->Conn->GetCol($sql); } /** @var LanguageImportHelper $language_import_helper */ $language_import_helper = $this->Application->recallObject('LanguageImportHelper'); $this->out('Exporting LanguagePack ... '); $language_import_helper->performExport( EXPORT_PATH . '/' . $this->moduleName . '.lang', '|0|1|2|', $languages, '|' . $this->moduleName . '|' ); $this->displayStatus('OK'); return $this; } /** * Resets unit and section cache. * * @return self */ private function resetCaches() { // 2. reset unit config cache (so new classes get auto-registered) $this->out('Resetting Configs Files Cache and Parsed System Data ... '); $this->_event->CallSubEvent('OnResetConfigsCache'); $this->displayStatus('OK'); // 3. reset sections cache $this->out('Resetting Admin Console Sections ... '); $this->_event->CallSubEvent('OnResetSections'); $this->displayStatus('OK'); // 4. reset mod-rewrite cache $this->out('Resetting ModRewrite Cache ... '); $this->_event->CallSubEvent('OnResetModRwCache'); $this->displayStatus('OK'); return $this; } /** * Rebuild theme files. * * @return self */ private function refreshThemes() { $this->out('Refreshing Theme Files ... '); $this->_event->CallSubEvent('OnRebuildThemes'); $this->displayStatus('OK'); return $this; } /** * Dumps assets * * @return void */ private function dumpAssets() { $this->out('Dumping Assets ... '); $this->_event->CallSubEvent('OnDumpAssets'); $this->displayStatus('OK'); } /** * Runs database upgrade script. * * @return boolean */ private function upgradeDatabase() { $this->loadAppliedRevisions(); $this->Conn->setErrorHandler(array(&$this, 'handleSqlError')); $this->out('Verifying Database Revisions ... '); if ( !$this->collectDatabaseRevisions() || !$this->checkRevisionDependencies() ) { return false; } $this->displayStatus('OK'); return $this->applyRevisions(); } /** * Collects database revisions from "project_upgrades.sql" file. * * @return boolean */ private function collectDatabaseRevisions() { $filename = $this->getModuleFile('project_upgrades.sql'); if ( !file_exists($filename) ) { return true; } $sqls = file_get_contents($filename); preg_match_all("/# r([\d]+)([^\:]*):(.*?)(\n|$)/s", $sqls, $matches, PREG_SET_ORDER + PREG_OFFSET_CAPTURE); if ( !$matches ) { $this->displayStatus('FAILED' . PHP_EOL . 'No Database Revisions Found'); return false; } $revision_numbers = array(); foreach ( $matches as $index => $match ) { $revision = $match[1][0]; if ( in_array($revision, $revision_numbers) ) { $this->displayStatus('FAILED' . PHP_EOL . 'Duplicate revision #' . $revision . ' found'); return false; } $revision_numbers[] = $revision; if ( $this->revisionApplied($revision) ) { // Skip applied revisions. continue; } // Get revision sqls. $start_pos = $match[0][1] + strlen($match[0][0]); $end_pos = isset($matches[$index + 1]) ? $matches[$index + 1][0][1] : strlen($sqls); $revision_sqls = substr($sqls, $start_pos, $end_pos - $start_pos); if ( !$revision_sqls ) { // revision without sqls continue; } $this->revisionTitles[$revision] = trim($match[3][0]); $this->revisionSqls[$revision] = $revision_sqls; $revision_dependencies = $this->parseRevisionDependencies($match[2][0]); if ( $revision_dependencies ) { $this->revisionDependencies[$revision] = $revision_dependencies; } } ksort($this->revisionSqls); ksort($this->revisionDependencies); return true; } /** * Checks that all dependent revisions are either present now OR were applied before. * * @return boolean */ private function checkRevisionDependencies() { foreach ( $this->revisionDependencies as $revision => $revision_dependencies ) { foreach ( $revision_dependencies as $revision_dependency ) { if ( $this->revisionApplied($revision_dependency) ) { // revision dependent upon already applied -> dependency fulfilled continue; } if ( $revision_dependency >= $revision ) { $this->displayStatus('FAILED' . PHP_EOL . 'Revision #' . $revision . ' has incorrect dependency to revision #' . $revision_dependency . '. Only dependencies to older revisions are allowed!'); return false; } if ( !isset($this->revisionSqls[$revision_dependency]) ) { $this->displayStatus('FAILED' . PHP_EOL . 'Revision #' . $revision . ' depends on missing revision #' . $revision_dependency . '!'); return false; } } } return true; } /** * Runs all pending sqls. * * @return boolean */ private function applyRevisions() { if ( !$this->revisionSqls ) { return true; } if ( $this->dryRun ) { $this->out('Simulating Database Upgrade ... ', true); foreach ( $this->revisionSqls as $revision => $sqls ) { $this->initLog($revision, ModuleDeploymentLog::MODE_MANUAL); echo PHP_EOL . $this->colorText($this->revisionTitles[$revision], 'gray', true) . PHP_EOL; // 'Processing DB Revision: #' . $revision . ' ... '; echo $this->toLog($this->colorText('SKIPPING', 'purple')); $this->saveLog(ModuleDeploymentLog::STATUS_SKIPPED); } echo PHP_EOL; return true; } $this->out('Upgrading Database ... ', true); foreach ( $this->revisionSqls as $revision => $sqls ) { echo PHP_EOL . $this->colorText($this->revisionTitles[$revision], 'gray', true) . PHP_EOL; // 'Processing DB Revision: #' . $revision . ' ... '; $sqls = str_replace("\r\n", "\n", $sqls); // convert to linux line endings $no_comment_sqls = preg_replace("/#\s([^;]*?)\n/is", "# \\1;\n", $sqls); // add ";" to each comment end to ensure correct split $sqls = explode(";\n", $no_comment_sqls . "\n"); // ensures that last sql won't have ";" in it $sqls = array_map('trim', $sqls); $this->initLog($revision); try { foreach ( $sqls as $sql ) { if ( substr($sql, 0, 1) == '#' ) { // output comment as is echo $this->toLog($this->colorText($sql, 'purple')); continue; } elseif ( $sql ) { echo $this->toLog($this->shortenQuery($sql), false); $this->Conn->Query($sql); $this->displayStatus('OK (' . $this->Conn->getAffectedRows() . ')', true, true); } } } catch ( Exception $e ) { // consider revisions with errors applied $this->saveLog(ModuleDeploymentLog::STATUS_ERROR); return false; } $this->saveLog(ModuleDeploymentLog::STATUS_SUCCESS); } echo PHP_EOL; return true; } /** * Returns shortened version of SQL query. * * @param string $sql SQL query. * * @return string */ protected function shortenQuery($sql) { $escaped_sql = $this->isCommandLine ? $sql : kUtil::escape($sql); $single_line_sql = preg_replace('/(\n|\t| )+/is', ' ', $escaped_sql); return mb_substr(trim($single_line_sql), 0, self::SQL_TRIM_LENGTH) . ' ... '; } /** * Initializes log record for a revision. * * @param integer $revision Revision. * @param integer $mode Mode. * * @return self */ protected function initLog($revision, $mode = ModuleDeploymentLog::MODE_AUTOMATIC) { $this->logData = array( 'Module' => $this->moduleName, 'RevisionNumber' => $revision, 'RevisionTitle' => $this->revisionTitles[$revision], 'IPAddress' => $this->ip, 'Output' => '', 'Mode' => $mode, 'Status' => ModuleDeploymentLog::STATUS_SUCCESS, ); return $this; } /** * Creates log record. * * @param integer $status Status. * * @return self */ private function saveLog($status) { $this->logData['Status'] = $status; $log = $this->Application->recallObject('module-deployment-log', null, array('skip_autoload' => true)); /* @var $log kDBItem */ $log->Clear(); $log->SetFieldsFromHash($this->logData); $log->Create(); return $this; } /** * Error handler for sql errors. * * @param int $code Error code. * @param string $msg Error message. * @param string $sql SQL query, that raised an error. * * @return void * @throws Exception When SQL error happens. */ public function handleSqlError($code, $msg, $sql) { $this->displayStatus('FAILED', true, true); $error_msg = 'SQL Error #' . $code . ': ' . $msg; $this->logData['ErrorMessage'] = $error_msg; $this->displayStatus($error_msg); $this->out('Please execute rest of SQLs in this Revision by hand and run deployment script again.', true); throw new Exception($msg, $code); } /** * Checks if given revision was already applied. * * @param int $revision Revision. * * @return boolean */ private function revisionApplied($revision) { return isset($this->appliedRevisions[$revision]); } /** * Returns path to given file in current module install folder. * * @param string $filename Filename. * * @return string */ private function getModuleFile($filename) { $module_folder = $this->Application->findModule('Name', $this->moduleName, 'Path'); return FULL_PATH . DIRECTORY_SEPARATOR . $module_folder . 'install/' . $filename; } /** * Extracts revisions from string in format "(1,3,5464,23342,3243)". * * @param string $string Comma-separated revision list. * * @return array */ private function parseRevisionDependencies($string) { if ( !$string ) { return array(); } $string = explode(',', substr($string, 1, -1)); return array_map('trim', $string); } /** * Applies requested color and bold attributes to given text string. * * @param string $text Text. * @param string $color Color. * @param boolean $bold Bold flag. * * @return string */ private function colorText($text, $color, $bold = false) { if ( $this->isCommandLine ) { $color_map = array( 'black' => 30, // dark gray (in bold) 'blue' => 34, // light blue (in bold) 'green' => 32, // light green (in bold) 'cyan' => 36, // light cyan (in bold) 'red' => 31, // light red (in bold) 'purple' => 35, // light purple (in bold) 'brown' => 33, // yellow (in bold) 'gray' => 37, // white (in bold) ); return "\033[" . ($bold ? 1 : 0) . ";" . $color_map[$color] . "m" . $text . "\033[0m"; } $html_color_map = array( 'black' => array('normal' => '#000000', 'bold' => '#666666'), 'blue' => array('normal' => '#00009C', 'bold' => '#3C3CFF'), 'green' => array('normal' => '#009000', 'bold' => '#00FF00'), 'cyan' => array('normal' => '#009C9C', 'bold' => '#00FFFF'), 'red' => array('normal' => '#9C0000', 'bold' => '#FF0000'), 'purple' => array('normal' => '#900090', 'bold' => '#F99CF9'), 'brown' => array('normal' => '#C9C909', 'bold' => '#FFFF00'), 'gray' => array('normal' => '#909090', 'bold' => '#FFFFFF'), ); $html_color = $html_color_map[$color][$bold ? 'bold' : 'normal']; return '' . kUtil::escape($text, kUtil::ESCAPE_HTML) . ''; } /** * Displays last command execution status. * * @param string $status_text Status text. * @param boolean $new_line Jump to next line. * @param boolean $to_log Also write to log. * * @return self */ private function displayStatus($status_text, $new_line = true, $to_log = false) { $color = substr($status_text, 0, 2) == 'OK' ? 'green' : 'red'; $ret = $this->colorText($status_text, $color, false); if ( $to_log ) { echo $this->toLog($ret, $new_line); } else { echo $ret . ($new_line ? PHP_EOL : ''); } return $this; } /** * Outputs a text and escapes it if necessary. * * @param string $text Text. * @param boolean $new_line Jump to next line. * * @return self */ private function out($text, $new_line = false) { if ( !$this->isCommandLine ) { $text = kUtil::escape($text); } echo $text . ($new_line ? PHP_EOL : ''); return $this; } }