diff --git a/application/clicommands/ConfigCommand.php b/application/clicommands/ConfigCommand.php index 318abd00..dbf92a62 100644 --- a/application/clicommands/ConfigCommand.php +++ b/application/clicommands/ConfigCommand.php @@ -5,10 +5,11 @@ namespace Icinga\Module\Director\Clicommands; use Icinga\Application\Benchmark; use Icinga\Module\Director\Cli\Command; use Icinga\Module\Director\Core\Json; +use Icinga\Module\Director\Deployment\ConditionalDeployment; +use Icinga\Module\Director\Deployment\DeploymentGracePeriod; use Icinga\Module\Director\Deployment\DeploymentStatus; use Icinga\Module\Director\IcingaConfig\IcingaConfig; use Icinga\Module\Director\Import\SyncUtils; -use Icinga\Module\Director\Objects\DirectorDeploymentLog; /** * Generate, show and deploy Icinga 2 configuration @@ -90,17 +91,20 @@ class ConfigCommand extends Command * USAGE * * icingacli director config deploy [--checksum ] [--force] [--wait ] + * [--grace-period ] * * OPTIONS * * --checksum Optionally deploy a specific configuration - * --force Force a deployment, even when the configuration hasn't - * changed - * --wait Optionally wait until Icinga completed it's restart + * --force Force a deployment, even when the configuration + * hasn't changed + * --wait Optionally wait until Icinga completed it's + * restart + * --grace-period Do not deploy if a deployment took place + * less than ago */ public function deployAction() { - $api = $this->api(); $db = $this->db(); $checksum = $this->params->get('checksum'); @@ -111,32 +115,31 @@ class ConfigCommand extends Command $checksum = $config->getHexChecksum(); } - $api->wipeInactiveStages($db); - $current = $api->getActiveChecksum($db); - if ($current === $checksum) { + $deployer = new ConditionalDeployment($db, $this->api()); + $deployer->force((bool) $this->params->get('force')); + if ($graceTime = $this->params->get('grace-period')) { + $deployer->setGracePeriod(new DeploymentGracePeriod((int) $graceTime, $db)); if ($this->params->get('force')) { - echo "Config matches active stage, deploying anyway\n"; - } else { - echo "Config matches active stage, nothing to do\n"; - - return; + fwrite(STDERR, "WARNING: force overrides Grace period\n"); } } + $deployer->refresh(); - $deploymentLog = $api->dumpConfig($config, $db); - if (! $deploymentLog) { - $this->fail("Failed to deploy config '%s'", $checksum); - } - if ($timeout = $this->params->get('wait')) { - if (! ctype_digit($timeout)) { - $this->fail("--wait must be the number of seconds to wait'"); + if ($deployment = $deployer->deploy($config)) { + if ($deployer->hasBeenForced()) { + echo $deployer->getNoDeploymentReason() . ", deploying anyway\n"; } - $deployed = $this->waitForStartupAfterDeploy($deploymentLog, $timeout); + printf("Config '%s' has been deployed\n", $checksum); + } else { + echo $deployer->getNoDeploymentReason() . "\n"; + } + + if ($timeout = $this->getWaitTime()) { + $deployed = $deployer->waitForStartupAfterDeploy($deployment, $timeout); if ($deployed !== true) { $this->fail("Failed to deploy config '%s': %s\n", $checksum, $deployed); } } - printf("Config '%s' has been deployed\n", $checksum); } /** @@ -159,24 +162,16 @@ class ConfigCommand extends Command } } - private function waitForStartupAfterDeploy($deploymentLog, $timeout) + protected function getWaitTime() { - $startTime = time(); - while ((time() - $startTime) <= $timeout) { - $deploymentFromDB = DirectorDeploymentLog::load($deploymentLog->getId(), $this->db()); - $stageCollected = $deploymentFromDB->get('stage_collected'); - if ($stageCollected === null) { - usleep(500000); - continue; + if ($timeout = $this->params->get('wait')) { + if (!ctype_digit($timeout)) { + $this->fail("--wait must be the number of seconds to wait'"); } - if ($stageCollected === 'n') { - return 'stage has not been collected'; - } - if ($deploymentFromDB->get('startup_succeeded') === 'y') { - return true; - } - return 'deployment failed during startup'; + + return (int) $timeout; } - return 'deployment timed out'; + + return null; } } diff --git a/doc/60-CLI.md b/doc/60-CLI.md index e2fc2414..0027c8a2 100644 --- a/doc/60-CLI.md +++ b/doc/60-CLI.md @@ -456,7 +456,22 @@ Config with checksum b330febd0820493fb12921ad8f5ea42102a5c871 already exists ### Config deployment -You do not need to explicitely render your config before deploying it to your +#### Usage + +`icingacli director config deploy [options]` + +#### Options + +| Option | Description | +|----------------------------|------------------------------------------------------------------| +| `checksum ` | Optionally deploy a specific configuration | +| `--force` | Force a deployment, even when the configuration hasn't changed | +| `--wait ` | Optionally wait until Icinga completed it's restart | +| `--grace-period ` | Do not deploy if a deployment took place less than ago | + +#### Examples + +You do not need to explicitly render your config before deploying it to your Icinga 2 master node. Just trigger a deployment, it will re-render the current config: @@ -490,6 +505,13 @@ version the `deploy` command allows you to provide a specific checksum: icingacli director config deploy --checksum b330febd0820493fb12921ad8f5ea42102a5c871 ``` +When using `icingacli` deployments in an automated way, and want to avoid fast +consecutive deployments, you can provide a grace period: + +```shell +icingacli director config deploy --grace-period 300 +``` + ### Deployments status In case you want to fetch the information about the deployments status, you can call the following CLI command: diff --git a/library/Director/Cli/Command.php b/library/Director/Cli/Command.php index 0383943c..fc878d4b 100644 --- a/library/Director/Cli/Command.php +++ b/library/Director/Cli/Command.php @@ -2,6 +2,7 @@ namespace Icinga\Module\Director\Cli; +use gipfl\Json\JsonString; use Icinga\Cli\Command as CliCommand; use Icinga\Module\Director\Application\MemoryLimit; use Icinga\Module\Director\Core\CoreApi; @@ -21,7 +22,7 @@ class Command extends CliCommand protected function renderJson($object, $pretty = true) { - return json_encode($object, $pretty ? JSON_PRETTY_PRINT : null) . "\n"; + return JsonString::encode($object, $pretty ? JSON_PRETTY_PRINT : null) . "\n"; } /** diff --git a/library/Director/Deployment/ConditionalConfigRenderer.php b/library/Director/Deployment/ConditionalConfigRenderer.php new file mode 100644 index 00000000..0b24418a --- /dev/null +++ b/library/Director/Deployment/ConditionalConfigRenderer.php @@ -0,0 +1,64 @@ +db = $connection; + } + + public function forceRendering($force = true) + { + $this->forceRendering = $force; + + return $this; + } + + public function getConfig() + { + if ($this->shouldGenerate()) { + return IcingaConfig::generate($this->db); + } + + return $this->loadLatestActivityConfig(); + } + + protected function loadLatestActivityConfig() + { + $db = $this->db; + + return IcingaConfig::loadByActivityChecksum($db->getLastActivityChecksum(), $db); + } + + protected function shouldGenerate() + { + return $this->forceRendering || !$this->configForLatestActivityExists(); + } + + protected function configForLatestActivityExists() + { + $db = $this->db; + try { + $latestActivity = DirectorActivityLog::loadLatest($db); + } catch (NotFoundError $e) { + return false; + } + + return IcingaConfig::existsForActivityChecksum( + bin2hex($latestActivity->get('checksum')), + $db + ); + } +} diff --git a/library/Director/Deployment/ConditionalDeployment.php b/library/Director/Deployment/ConditionalDeployment.php new file mode 100644 index 00000000..4dbd0bce --- /dev/null +++ b/library/Director/Deployment/ConditionalDeployment.php @@ -0,0 +1,190 @@ +setLogger(new NullLogger()); + $this->db = $connection; + if ($api === null) { + $this->api = $connection->getDeploymentEndpoint()->api(); + } else { + $this->api = $api; + } + $this->refresh(); + } + + /** + * @param IcingaConfig $config + * @return ?DirectorDeploymentLog + */ + public function deploy(IcingaConfig $config) + { + $this->hasBeenForced = false; + if ($this->shouldDeploy($config)) { + return $this->reallyDeploy($config); + } elseif ($this->force) { + $deployment = $this->reallyDeploy($config); + $this->hasBeenForced = true; + + return $deployment; + } + + return null; + } + + /** + * @param bool $force + * @return $this + */ + public function force($force = true) + { + $this->force = $force; + return $this; + } + + public function setGracePeriod(DeploymentGracePeriod $gracePeriod) + { + $this->gracePeriod = $gracePeriod; + return $this; + } + + public function refresh() + { + $this->api->collectLogFiles($this->db); + $this->api->wipeInactiveStages($this->db); + } + + public function waitForStartupAfterDeploy(DirectorDeploymentLog $deploymentLog, $timeout) + { + $startTime = time(); + while ((time() - $startTime) <= $timeout) { + $deploymentFromDB = DirectorDeploymentLog::load($deploymentLog->getId(), $this->db); + $stageCollected = $deploymentFromDB->get('stage_collected'); + if ($stageCollected === null) { + usleep(500000); + continue; + } + if ($stageCollected === 'n') { + return 'stage has not been collected'; + } + if ($deploymentFromDB->get('startup_succeeded') === 'y') { + return true; + } + return 'deployment failed during startup'; + } + return 'deployment timed out'; + } + + /** + * @return string|null + */ + public function getNoDeploymentReason() + { + return $this->noDeploymentReason; + } + + public function hasBeenForced() + { + return $this->hasBeenForced; + } + + protected function shouldDeploy(IcingaConfig $config) + { + $this->noDeploymentReason = null; + if ($this->hasNeverDeployed()) { + return true; + } + + if ($this->isWithinGracePeriod()) { + $this->noDeploymentReason = 'Grace period is active'; + return false; + } + + if ($this->deployedConfigMatches($config)) { + $this->noDeploymentReason = 'Config matches last deployed one'; + return false; + } + + if ($this->getActiveChecksum() === $config->getHexChecksum()) { + $this->noDeploymentReason = 'Config matches active stage'; + return false; + } + + return true; + } + + protected function hasNeverDeployed() + { + return !DirectorDeploymentLog::hasDeployments($this->db); + } + + protected function isWithinGracePeriod() + { + return $this->gracePeriod && $this->gracePeriod->isActive(); + } + + protected function deployedConfigMatches(IcingaConfig $config) + { + if ($deployment = DirectorDeploymentLog::optionalLatest($this->db)) { + return $deployment->getConfigHexChecksum() === $config->getHexChecksum(); + } + + return false; + } + + protected function getActiveChecksum() + { + return DirectorDeploymentLog::getConfigChecksumForStageName( + $this->db, + $this->api->getActiveStageName() + ); + } + + /** + * @param IcingaConfig $config + * @return bool|DirectorDeploymentLog + * @throws IcingaException + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function reallyDeploy(IcingaConfig $config) + { + $checksum = $config->getHexChecksum(); + $this->logger->info(sprintf('Director ConfigJob ready to deploy "%s"', $checksum)); + if ($deployment = $this->api->dumpConfig($config, $this->db)) { + $this->logger->notice(sprintf('Director ConfigJob deployed config "%s"', $checksum)); + return $deployment; + } else { + throw new IcingaException('Failed to deploy config "%s"', $checksum); + } + } +} diff --git a/library/Director/Deployment/DeploymentGracePeriod.php b/library/Director/Deployment/DeploymentGracePeriod.php new file mode 100644 index 00000000..6cde25af --- /dev/null +++ b/library/Director/Deployment/DeploymentGracePeriod.php @@ -0,0 +1,61 @@ +graceTimeSeconds = $graceTimeSeconds; + $this->db = $db; + } + + /** + * Whether we're still within a grace period + * @return bool + */ + public function isActive() + { + if ($deployment = $this->lastDeployment()) { + return $deployment->getDeploymentTimestamp() > $this->getGracePeriodStart(); + } + + return false; + } + + protected function getGracePeriodStart() + { + return time() - $this->graceTimeSeconds; + } + + public function getRemainingGraceTime() + { + if ($this->isActive()) { + if ($deployment = $this->lastDeployment()) { + return $deployment->getDeploymentTimestamp() - $this->getGracePeriodStart(); + } else { + return null; + } + } + + return 0; + } + + protected function lastDeployment() + { + return DirectorDeploymentLog::optionalLatest($this->db); + } +} diff --git a/library/Director/Hook/JobHook.php b/library/Director/Hook/JobHook.php index 51f74c65..d9a81a95 100644 --- a/library/Director/Hook/JobHook.php +++ b/library/Director/Hook/JobHook.php @@ -83,30 +83,4 @@ abstract class JobHook { return $this->db; } - - /** - * printf helper method - * - * @param string $message Format string - * @param mixed ...$arg Format string argument - * - * @return self - */ - protected function info($message) - { - call_user_func_array(array('Icinga\\Application\\Logger', 'info'), func_get_args()); - return $this; - } - - protected function warning($message) - { - call_user_func_array(array('Icinga\\Application\\Logger', 'warn'), func_get_args()); - return $this; - } - - protected function error($message) - { - call_user_func_array(array('Icinga\\Application\\Logger', 'error'), func_get_args()); - return $this; - } } diff --git a/library/Director/Job/ConfigJob.php b/library/Director/Job/ConfigJob.php index ad607230..fda3043d 100644 --- a/library/Director/Job/ConfigJob.php +++ b/library/Director/Job/ConfigJob.php @@ -2,182 +2,32 @@ namespace Icinga\Module\Director\Job; -use Exception; -use Icinga\Exception\IcingaException; -use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Deployment\ConditionalConfigRenderer; +use Icinga\Module\Director\Deployment\ConditionalDeployment; +use Icinga\Module\Director\Deployment\DeploymentGracePeriod; use Icinga\Module\Director\Hook\JobHook; -use Icinga\Module\Director\Objects\DirectorActivityLog; -use Icinga\Module\Director\Objects\DirectorDeploymentLog; -use Icinga\Module\Director\Util; use Icinga\Module\Director\Web\Form\QuickForm; class ConfigJob extends JobHook { - protected $lastDeployment; - - protected $api; - public function run() { $db = $this->db(); - $this->clearLastDeployment(); - - if ($this->shouldGenerate()) { - $config = IcingaConfig::generate($db); - } else { - $config = $this->loadLatestActivityConfig(); + $deployer = new ConditionalDeployment($db); + $renderer = new ConditionalConfigRenderer($db); + if ($grace = $this->getSetting('grace_period')) { + $deployer->setGracePeriod(new DeploymentGracePeriod((int) $grace, $db)); + } + if ($this->getSetting('force_generate') === 'y') { + $renderer->forceRendering(); } - if ($this->shouldDeploy($config)) { - $this->deploy($config); - } - - $this->clearLastDeployment(); - } - - protected function api() - { - if ($this->api === null) { - $this->api = $this->db()->getDeploymentEndpoint()->api(); - } - - return $this->api; - } - - protected function loadLatestActivityConfig() - { - $db = $this->db(); - - return IcingaConfig::loadByActivityChecksum( - $db->getLastActivityChecksum(), - $db - ); - } - - protected function shouldGenerate() - { - return $this->getSetting('force_generate') === 'y' - || ! $this->configForLatestActivityExists(); - } - - protected function configForLatestActivityExists() - { - $db = $this->db(); - - return IcingaConfig::existsForActivityChecksum( - bin2hex(DirectorActivityLog::loadLatest($db)->checksum), - $db - ); - } - - protected function shouldDeploy(IcingaConfig $config) - { - $db = $this->db(); - if ($this->getSetting('deploy_when_changed') !== 'y') { - return false; - } - $api = $this->api(); - $api->collectLogFiles($db); - - if (! DirectorDeploymentLog::hasDeployments($db)) { - return true; - } - - if ($this->isWithinGracePeriod()) { - return false; - } - - if (DirectorDeploymentLog::loadLatest($db)->getConfigHexChecksum() - === $config->getHexChecksum() - ) { - return false; - } - - if ($this->getActiveChecksum() === $config->getHexChecksum()) { - return false; - } - - return true; - } - - protected function deploy(IcingaConfig $config) - { - $db = $this->db(); - $api = $this->api(); - $api->wipeInactiveStages($db); - - $checksum = $config->getHexChecksum(); - $this->info('Director ConfigJob ready to deploy "%s"', $checksum); - if ($api->dumpConfig($config, $db)) { - $this->info('Director ConfigJob deployed config "%s"', $checksum); - - // TODO: Loop and try multiple times? - sleep(2); - try { - $api->collectLogFiles($db); - } catch (Exception $e) { - // Ignore those errors, Icinga may be reloading - } - } else { - throw new IcingaException('Failed to deploy config "%s"', $checksum); - } - } - - protected function getGracePeriodStart() - { - return time() - $this->getSetting('grace_period'); - } - - public function getRemainingGraceTime() - { - if ($this->isWithinGracePeriod()) { - if ($deployment = $this->lastDeployment()) { - return $deployment->getDeploymentTimestamp() - + $this->getSetting('grace_period') - - time(); - } else { - return null; - } - } - - return 0; - } - - protected function isWithinGracePeriod() - { - if ($deployment = $this->lastDeployment()) { - return $deployment->getDeploymentTimestamp() > $this->getGracePeriodStart(); - } - - return false; - } - - protected function getActiveChecksum() - { - return DirectorDeploymentLog::getConfigChecksumForStageName( - $this->db(), - $this->api()->getActiveStageName() - ); - } - - protected function lastDeployment() - { - if ($this->lastDeployment === null) { - $this->lastDeployment = DirectorDeploymentLog::loadLatest($this->db()); - } - - return $this->lastDeployment; - } - - protected function clearLastDeployment() - { - $this->lastDeployment = null; - return $this; + $deployer->deploy($renderer->getConfig()); } public static function addSettingsFormFields(QuickForm $form) { - $form->addElement('select', 'force_generate', array( + $form->addElement('select', 'force_generate', [ 'label' => $form->translate('Force rendering'), 'description' => $form->translate( 'Whether rendering should be forced. If not enforced, this' @@ -185,23 +35,23 @@ class ConfigJob extends JobHook . ' activities since the last rendered config' ), 'value' => 'n', - 'multiOptions' => array( + 'multiOptions' => [ 'y' => $form->translate('Yes'), 'n' => $form->translate('No'), - ) - )); + ] + ]); - $form->addElement('select', 'deploy_when_changed', array( + $form->addElement('select', 'deploy_when_changed', [ 'label' => $form->translate('Deploy modified config'), 'description' => $form->translate( 'This allows you to immediately deploy a modified configuration' ), 'value' => 'n', - 'multiOptions' => array( + 'multiOptions' => [ 'y' => $form->translate('Yes'), 'n' => $form->translate('No'), - ) - )); + ] + ]); $form->addElement('text', 'grace_period', array( 'label' => $form->translate('Grace period'), diff --git a/library/Director/Objects/DirectorActivityLog.php b/library/Director/Objects/DirectorActivityLog.php index 6b4bcc3e..9e00b0ec 100644 --- a/library/Director/Objects/DirectorActivityLog.php +++ b/library/Director/Objects/DirectorActivityLog.php @@ -84,6 +84,11 @@ class DirectorActivityLog extends DbObject } } + /** + * @param Db $connection + * @return DirectorActivityLog + * @throws \Icinga\Exception\NotFoundError + */ public static function loadLatest(Db $connection) { $db = $connection->getDbAdapter(); diff --git a/library/Director/Objects/DirectorDeploymentLog.php b/library/Director/Objects/DirectorDeploymentLog.php index fbe61b11..0794a3c3 100644 --- a/library/Director/Objects/DirectorDeploymentLog.php +++ b/library/Director/Objects/DirectorDeploymentLog.php @@ -120,6 +120,19 @@ class DirectorDeploymentLog extends DbObject return static::load($db->fetchOne($query), $connection); } + /** + * @param Db $connection + * @return ?DirectorDeploymentLog + */ + public static function optionalLatest(Db $connection) + { + try { + return static::loadLatest($connection); + } catch (NotFoundError $exception) { + return null; + } + } + /** * @param CoreApi $api * @param Db $connection