diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 13:17:31 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 13:17:31 +0000 |
commit | f66ab8dae2f3d0418759f81a3a64dc9517a62449 (patch) | |
tree | fbff2135e7013f196b891bbde54618eb050e4aaf /application/forms | |
parent | Initial commit. (diff) | |
download | icingaweb2-module-director-f66ab8dae2f3d0418759f81a3a64dc9517a62449.tar.xz icingaweb2-module-director-f66ab8dae2f3d0418759f81a3a64dc9517a62449.zip |
Adding upstream version 1.10.2.upstream/1.10.2
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'application/forms')
60 files changed, 8817 insertions, 0 deletions
diff --git a/application/forms/AddToBasketForm.php b/application/forms/AddToBasketForm.php new file mode 100644 index 0000000..44b5357 --- /dev/null +++ b/application/forms/AddToBasketForm.php @@ -0,0 +1,129 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use gipfl\Web\Widget\Hint; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use gipfl\IcingaWeb2\Link; +use Icinga\Module\Director\DirectorObject\Automation\Basket; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class AddToBasketForm extends DirectorForm +{ + /** @var Basket */ + private $basket; + + private $type = '(has not been set)'; + + private $names = []; + + /** + * @throws \Zend_Form_Exception + * @throws \Icinga\Exception\NotFoundError + */ + public function setup() + { + $db = $this->getDb()->getDbAdapter(); + $enum = $db->fetchPairs($db->select()->from('director_basket', [ + 'a' => 'basket_name', + 'b' => 'basket_name', + ])->order('basket_name')); + + $names = []; + $basket = null; + if ($this->hasBeenSent()) { + $basketName = $this->getSentValue('basket'); + if ($basketName) { + $basket = Basket::load($basketName, $this->getDb()); + } + } + $count = 0; + $type = $this->type; + foreach ($this->names as $name) { + if (! empty($names)) { + $names[] = ', '; + } + if ($basket && $basket->hasObject($type, $name)) { + $names[] = Html::tag('span', [ + 'style' => 'text-decoration: line-through' + ], $name); + } else { + $count++; + $names[] = $name; + } + } + $this->addHtmlHint((new HtmlDocument())->add([ + 'The following objects will be added: ', + $names + ])); + $this->addElement('select', 'basket', [ + 'label' => $this->translate('Basket'), + 'multiOptions' => $this->optionalEnum($enum), + 'required' => true, + 'class' => 'autosubmit', + ]); + + if ($count > 0) { + $this->setSubmitLabel(sprintf( + $this->translate('Add %s objects'), + $count + )); + } else { + $this->setSubmitLabel($this->translate('Add')); + $this->addSubmitButtonIfSet(); + $this->getElement($this->submitButtonName)->setAttrib('disabled', true); + } + } + + public function setType($type) + { + $this->type = $type; + + return $this; + } + + public function setNames($names) + { + $this->names = $names; + + return $this; + } + + /** + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function onSuccess() + { + $type = $this->type; + $basket = Basket::load($this->getValue('basket'), $this->getDb()); + $basketName = $basket->get('basket_name'); + + if (empty($this->names)) { + $this->getElement('basket')->addErrorMessage($this->translate( + 'No object has been chosen' + )); + } + if ($basket->supportsCustomSelectionFor($type)) { + $basket->addObjects($type, $this->names); + $basket->store(); + $this->setSuccessMessage(sprintf($this->translate( + 'Configuration objects have been added to the chosen basket "%s"' + ), $basketName)); + return parent::onSuccess(); + } else { + $this->addHtmlHint(Hint::error(Html::sprintf($this->translate( + 'Please check your Basket configuration, %s does not support' + . ' single "%s" configuration objects' + ), Link::create( + $basketName, + 'director/basket', + ['name' => $basketName], + ['data-base-target' => '_next'] + ), $type))); + + return false; + } + } +} diff --git a/application/forms/ApplyMigrationsForm.php b/application/forms/ApplyMigrationsForm.php new file mode 100644 index 0000000..4f1e62b --- /dev/null +++ b/application/forms/ApplyMigrationsForm.php @@ -0,0 +1,54 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Exception; +use Icinga\Module\Director\Db\Migrations; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class ApplyMigrationsForm extends DirectorForm +{ + /** @var Migrations */ + protected $migrations; + + public function setup() + { + if ($this->migrations->hasSchema()) { + $count = $this->migrations->countPendingMigrations(); + if ($count === 1) { + $this->setSubmitLabel( + $this->translate('Apply a pending schema migration') + ); + } else { + $this->setSubmitLabel( + sprintf( + $this->translate('Apply %d pending schema migrations'), + $count + ) + ); + } + } else { + $this->setSubmitLabel($this->translate('Create schema')); + } + } + + public function onSuccess() + { + try { + $this->setSuccessMessage($this->translate( + 'Pending database schema migrations have successfully been applied' + )); + + $this->migrations->applyPendingMigrations(); + parent::onSuccess(); + } catch (Exception $e) { + $this->addError($e->getMessage()); + } + } + + public function setMigrations(Migrations $migrations) + { + $this->migrations = $migrations; + return $this; + } +} diff --git a/application/forms/BasketCreateSnapshotForm.php b/application/forms/BasketCreateSnapshotForm.php new file mode 100644 index 0000000..165c7ac --- /dev/null +++ b/application/forms/BasketCreateSnapshotForm.php @@ -0,0 +1,37 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\DirectorObject\Automation\Basket; +use Icinga\Module\Director\DirectorObject\Automation\BasketSnapshot; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class BasketCreateSnapshotForm extends DirectorForm +{ + /** @var Basket */ + private $basket; + + public function setBasket(Basket $basket) + { + $this->basket = $basket; + + return $this; + } + + public function setup() + { + $this->setSubmitLabel($this->translate('Create Snapshot')); + } + + /** + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function onSuccess() + { + /** @var \Icinga\Module\Director\Db $connection */ + $connection = $this->basket->getConnection(); + $snapshot = BasketSnapshot::createForBasket($this->basket, $connection); + $snapshot->store(); + parent::onSuccess(); + } +} diff --git a/application/forms/BasketForm.php b/application/forms/BasketForm.php new file mode 100644 index 0000000..8ff6cca --- /dev/null +++ b/application/forms/BasketForm.php @@ -0,0 +1,147 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\DirectorObject\Automation\Basket; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Zend_Form_SubForm as ZfSubForm; + +class BasketForm extends DirectorObjectForm +{ + protected $listUrl = 'director/baskets'; + + protected function getAvailableTypes() + { + return [ + 'Command' => $this->translate('Command Definitions'), + 'ExternalCommand' => $this->translate('External Command Definitions'), + 'CommandTemplate' => $this->translate('Command Template'), + 'HostGroup' => $this->translate('Host Group'), + 'IcingaTemplateChoiceHost' => $this->translate('Host Template Choice'), + 'HostTemplate' => $this->translate('Host Templates'), + 'ServiceGroup' => $this->translate('Service Groups'), + 'IcingaTemplateChoiceService' => $this->translate('Service Template Choice'), + 'ServiceTemplate' => $this->translate('Service Templates'), + 'ServiceSet' => $this->translate('Service Sets'), + 'UserGroup' => $this->translate('User Groups'), + 'UserTemplate' => $this->translate('User Templates'), + 'User' => $this->translate('Users'), + 'NotificationTemplate' => $this->translate('Notification Templates'), + 'Notification' => $this->translate('Notifications'), + 'TimePeriod' => $this->translate('Time Periods'), + 'Dependency' => $this->translate('Dependencies'), + 'DataList' => $this->translate('Data Lists'), + 'ImportSource' => $this->translate('Import Sources'), + 'SyncRule' => $this->translate('Sync Rules'), + 'DirectorJob' => $this->translate('Job Definitions'), + 'Basket' => $this->translate('Basket Definitions'), + ]; + } + + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->addElement('text', 'basket_name', [ + 'label' => $this->translate('Basket Name'), + 'required' => true, + ]); + + $types = $this->getAvailableTypes(); + + $options = [ + 'IGNORE' => $this->translate('Ignore'), + 'ALL' => $this->translate('All of them'), + '[]' => $this->translate('Custom Selection'), + ]; + + $this->addHtmlHint($this->translate( + 'What should we place into this Basket every time we create' + . ' new snapshot?' + )); + + $sub = new ZfSubForm(); + $sub->setDecorators([ + ['HtmlTag', ['tag' => 'dl']], + 'FormElements' + ]); + + foreach ($types as $name => $label) { + $sub->addElement('select', $name, [ + 'label' => $label, + 'multiOptions' => $options, + ]); + } + + $this->addSubForm($sub, 'objects'); + $this->addDeleteButton(); + + $this->addHtmlHint($this->translate( + 'Choose "All" to always add all of them,' + . ' "Ignore" to not care about a specific Type at all and' + . ' opt for "Custom Selection" in case you want to choose' + . ' just some specific Objects.' + )); + } + + protected function setDefaultsFromObject(DbObject $object) + { + parent::setDefaultsFromObject($object); + /** @var Basket $object */ + $values = []; + foreach ($this->getAvailableTypes() as $type => $label) { + $values[$type] = 'IGNORE'; + } + foreach ($object->getChosenObjects() as $type => $selection) { + if ($selection === true) { + $values[$type] = 'ALL'; + } elseif (is_array($selection)) { + $values[$type] = '[]'; + } + } + + $this->populate([ + 'objects' => $values + ]); + } + + protected function onRequest() + { + parent::onRequest(); // TODO: Change the autogenerated stub + } + + protected function getObjectClassname() + { + return Basket::class; + } + + public function onSuccess() + { + /** @var Basket $basket */ + $basket = $this->object(); + + if ($basket->isEmpty()) { + $this->addError($this->translate("It's not allowed to store an empty basket")); + + return; + } + if (! $basket->hasBeenLoadedFromDb()) { + $basket->set('owner_type', 'user'); + $basket->set('owner_value', $this->getAuth()->getUser()->getUsername()); + } + + parent::onSuccess(); + } + + protected function setObjectSuccessUrl() + { + /** @var Basket $basket */ + $basket = $this->object(); + $this->setSuccessUrl( + 'director/basket', + ['name' => $basket->get('basket_name')] + ); + } +} diff --git a/application/forms/BasketUploadForm.php b/application/forms/BasketUploadForm.php new file mode 100644 index 0000000..a88dc06 --- /dev/null +++ b/application/forms/BasketUploadForm.php @@ -0,0 +1,147 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Exception; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Core\Json; +use Icinga\Module\Director\DirectorObject\Automation\Basket; +use Icinga\Module\Director\DirectorObject\Automation\BasketSnapshot; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Web\Notification; + +class BasketUploadForm extends DirectorObjectForm +{ + protected $listUrl = 'director/baskets'; + + protected $failed; + + protected $upload; + + protected $rawUpload; + + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->addElement('text', 'basket_name', [ + 'label' => $this->translate('Basket Name'), + 'required' => true, + ]); + $this->setAttrib('enctype', 'multipart/form-data'); + + $this->addElement('file', 'uploaded_file', [ + 'label' => $this->translate('Choose file'), + 'destination' => $this->getTempDir(), + 'valueDisabled' => true, + 'isArray' => false, + 'multiple' => false, + 'ignore' => true, + ]); + + $this->setSubmitLabel($this->translate('Upload')); + } + + protected function getTempDir() + { + return sys_get_temp_dir(); + } + + protected function getObjectClassname() + { + return '\\Icinga\\Module\\Director\\DirectorObject\\Automation\\Basket'; + } + + protected function setObjectSuccessUrl() + { + /** @var Basket $basket */ + $basket = $this->object(); + $this->setSuccessUrl( + 'director/basket', + ['name' => $basket->get('basket_name')] + ); + } + + /** + * @return bool + * @throws IcingaException + */ + protected function processUploadedSource() + { + if (! array_key_exists('uploaded_file', $_FILES)) { + throw new IcingaException('Got no file'); + } + + if (! isset($_FILES['uploaded_file']['tmp_name']) + || ! is_uploaded_file($_FILES['uploaded_file']['tmp_name']) + ) { + $this->addError('Got no uploaded file'); + $this->failed = true; + + return false; + } + $tmpFile = $_FILES['uploaded_file']['tmp_name']; + $originalFilename = $_FILES['uploaded_file']['name']; + + $source = file_get_contents($tmpFile); + unlink($tmpFile); + try { + $json = Json::decode($source); + $this->rawUpload = $source; + $this->upload = $json; + } catch (Exception $e) { + $this->addError($originalFilename . ' failed: ' . $e->getMessage()); + Notification::error($originalFilename . ' failed: ' . $e->getMessage()); + $this->failed = true; + + return false; + } + + return true; + } + + public function onRequest() + { + if ($this->hasBeenSent()) { + try { + $this->processUploadedSource(); + } catch (Exception $e) { + $this->addError($e->getMessage()); + return; + } + } + } + + /** + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function onSuccess() + { + /** @var Basket $basket */ + $basket = $this->object(); + + foreach ($this->upload as $type => $content) { + if ($type !== 'Datafield') { + $basket->addObjects($type, array_keys((array) $content)); + } + } + if ($basket->isEmpty()) { + $this->addError($this->translate("It's not allowed to store an empty basket")); + + return; + } + + $basket->set('owner_type', 'user'); + $basket->set('owner_value', $this->getAuth()->getUser()->getUsername()); + $basket->store($this->db); + + BasketSnapshot::forBasketFromJson( + $basket, + $this->rawUpload + )->store($this->db); + $this->setObjectSuccessUrl(); + $this->beforeSuccessfulRedirect(); + $this->redirectOnSuccess($this->translate('Basket has been uploaded')); + } +} diff --git a/application/forms/CustomvarForm.php b/application/forms/CustomvarForm.php new file mode 100644 index 0000000..759464c --- /dev/null +++ b/application/forms/CustomvarForm.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\QuickForm; + +class CustomvarForm extends QuickForm +{ + protected $submitLabel = false; + + public function setup() + { + $this->removeCsrfToken(); + $this->removeElement(self::ID); + $this->addElement('text', 'varname', array( + 'label' => $this->translate('Variable name'), + 'required' => true, + )); + + $this->addElement('text', 'varvalue', array( + 'label' => $this->translate('Value'), + )); + + // $this->addHidden('format', 'string'); // expression, json? + } +} diff --git a/application/forms/DeployConfigForm.php b/application/forms/DeployConfigForm.php new file mode 100644 index 0000000..0b817fa --- /dev/null +++ b/application/forms/DeployConfigForm.php @@ -0,0 +1,121 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Core\DeploymentApiInterface; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class DeployConfigForm extends DirectorForm +{ + use DeployFormsBug7530; + + /** @var DeploymentApiInterface */ + private $api; + + /** @var string */ + private $checksum; + + /** @var int */ + private $deploymentId; + + public function init() + { + $this->setAttrib('class', 'inline'); + } + + public function setup() + { + $activities = $this->db->countActivitiesSinceLastDeployedConfig(); + if ($this->deploymentId) { + $label = $this->translate('Re-deploy now'); + } elseif ($activities === 0) { + $label = $this->translate('There are no pending changes. Deploy anyway'); + } else { + $label = sprintf( + $this->translate('Deploy %d pending changes'), + $activities + ); + } + + if ($this->deploymentId) { + $deployIcon = 'reply-all'; + } else { + $deployIcon = 'forward'; + } + + $this->addHtml( + $this->getView()->icon( + $deployIcon, + $label, + array('class' => 'link-color') + ) . '<nobr>' + ); + + $el = $this->createElement('submit', 'btn_deploy', array( + 'label' => $label, + 'escape' => false, + 'decorators' => array('ViewHelper'), + 'class' => 'link-button ' . $deployIcon, + )); + + $this->addHtml('</nobr>'); + $this->submitButtonName = $el->getName(); + $this->setSubmitLabel($label); + $this->addElement($el); + } + + public function onSuccess() + { + if ($this->skipBecauseOfBug7530()) { + return; + } + + $db = $this->db; + $msg = $this->translate('Config has been submitted, validation is going on'); + $this->setSuccessMessage($msg); + + $isApiRequest = $this->getRequest()->isApiRequest(); + if ($this->checksum) { + $config = IcingaConfig::load(hex2bin($this->checksum), $db); + } else { + $config = IcingaConfig::generate($db); + } + + $this->api->wipeInactiveStages($db); + + if ($this->api->dumpConfig($config, $db)) { + if ($isApiRequest) { + die('Api not ready'); + // return $this->sendJson((object) array('checksum' => $checksum)); + } else { + $this->setSuccessUrl('director/config/deployments'); + $this->setSuccessMessage( + $this->translate('Config has been submitted, validation is going on') + ); + } + parent::onSuccess(); + } else { + throw new IcingaException($this->translate('Config deployment failed')); + } + } + + public function setChecksum($checksum) + { + $this->checksum = $checksum; + return $this; + } + + public function setDeploymentId($id) + { + $this->deploymentId = $id; + return $this; + } + + public function setApi(DeploymentApiInterface $api) + { + $this->api = $api; + return $this; + } +} diff --git a/application/forms/DeployFormsBug7530.php b/application/forms/DeployFormsBug7530.php new file mode 100644 index 0000000..4d456ae --- /dev/null +++ b/application/forms/DeployFormsBug7530.php @@ -0,0 +1,126 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use gipfl\Web\Widget\Hint; +use Icinga\Module\Director\Core\CoreApi; +use ipl\Html\Html; + +trait DeployFormsBug7530 +{ + public function hasBeenSubmitted() + { + if (parent::hasBeenSubmitted()) { + return true; + } else { + return \strlen($this->getSentValue('confirm_7530')) > 0; + } + } + + protected function shouldWarnAboutBug7530() + { + /** @var \Icinga\Module\Director\Db $db */ + $db = $this->getDb(); + + return $db->settings()->get('ignore_bug7530') !== 'y' + && $this->getSentValue('confirm_7530') !== 'i_know' + && $this->configMightTriggerBug7530() + & $this->coreHasBug7530(); + } + + protected function configMightTriggerBug7530() + { + /** @var \Icinga\Module\Director\Db $connection */ + $connection = $this->getDb(); + $db = $connection->getDbAdapter(); + + $zoneIds = $db->fetchCol( + $db->select() + ->from('icinga_zone', 'id') + ->where('object_type = ?', 'object') + ); + if (empty($zoneIds)) { + return false; + } + + $objectTypes = [ + 'icinga_host', + 'icinga_service', + 'icinga_notification', + 'icinga_command', + ]; + + foreach ($objectTypes as $objectType) { + if ((int) $db->fetchOne( + $db->select() + ->from($objectType, 'COUNT(*)') + ->where('zone_id IN (?)', $zoneIds) + ) > 0) { + return true; + } + } + + return false; + } + + protected function coreHasBug7530() + { + // TODO: Cache this + if ($this->api instanceof CoreApi) { + $version = $this->api->getVersion(); + if ($version === null) { + throw new \RuntimeException($this->translate('Unable to detect your Icinga 2 Core version')); + } elseif (\version_compare($version, '2.11.0', '>=') + && \version_compare($version, '2.12.0', '<') + ) { + return true; + } + } + + return false; + } + + public function skipBecauseOfBug7530() + { + $bug7530 = $this->getSentValue('confirm_7530'); + if ($bug7530 === 'whaaat') { + $this->setSuccessMessage($this->translate('Config has not been deployed')); + parent::onSuccess(); + } elseif ($bug7530 === 'hell_yes') { + $this->db->settings()->set('ignore_bug7530', 'y'); + } + if ($this->shouldWarnAboutBug7530()) { + $this->addHtml(Hint::warning(Html::sprintf($this->translate( + "Warning: you're running Icinga v2.11.0 and our configuration looks" + . " like you could face issue %s. We're already working on a solution." + . " The GitHub Issue and our %s contain related details." + ), Html::tag('a', [ + 'href' => 'https://github.com/Icinga/icinga2/issues/7530', + 'target' => '_blank', + 'title' => sprintf( + $this->translate('Show Issue %s on GitHub'), + '7530' + ), + 'class' => 'icon-github-circled', + ], '#7530'), Html::tag('a', [ + 'href' => 'https://icinga.com/docs/icinga2/latest/doc/16-upgrading-icinga-2/' + . '#config-sync-zones-in-zones', + 'target' => '_blank', + 'title' => $this->translate('Upgrading Icinga 2 - Confic Sync: Zones in Zones'), + 'class' => 'icon-info-circled', + ], $this->translate('Upgrading documentation'))))); + $this->addElement('select', 'confirm_7530', [ + 'multiOptions' => $this->optionalEnum([ + 'i_know' => $this->translate("I know what I'm doing, deploy anyway"), + 'hell_yes' => $this->translate("I know, please don't bother me again"), + 'whaaat' => $this->translate("Thanks, I'll verify this and come back later"), + ]), + 'class' => 'autosubmit', + 'decorators' => ['ViewHelper'], + ]); + return true; + } + + return false; + } +} diff --git a/application/forms/DeploymentLinkForm.php b/application/forms/DeploymentLinkForm.php new file mode 100644 index 0000000..f42a627 --- /dev/null +++ b/application/forms/DeploymentLinkForm.php @@ -0,0 +1,170 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Authentication\Auth; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Core\DeploymentApiInterface; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Deployment\DeploymentInfo; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Web\Form\DirectorForm; +use gipfl\IcingaWeb2\Icon; +use Zend_View_Interface; + +class DeploymentLinkForm extends DirectorForm +{ + use DeployFormsBug7530; + + /** @var DeploymentInfo */ + protected $info; + + /** @var Auth */ + protected $auth; + + /** @var DeploymentApiInterface */ + protected $api; + + /** @var Db */ + protected $db; + + /** + * @param DeploymentInfo $info + * @param Auth $auth + * @return static + */ + public static function create(Db $db, DeploymentInfo $info, Auth $auth, DeploymentApiInterface $api) + { + $self = static::load(); + $self->setAuth($auth); + $self->db = $db; + $self->info = $info; + $self->api = $api; + return $self; + } + + public function setAuth(Auth $auth) + { + $this->auth = $auth; + return $this; + } + + public function setup() + { + if (! $this->canDeploy()) { + return; + } + + $onObject = $this->info->getSingleObjectChanges(); + $total = $this->info->getTotalChanges(); + + if ($onObject === 0) { + if ($total === 1) { + $msg = $this->translate('There is a single pending change'); + } else { + $msg = sprintf( + $this->translate('There are %d pending changes'), + $total + ); + } + } elseif ($total === 1) { + $msg = $this->translate('There has been a single change to this object, nothing else has been modified'); + } elseif ($total === $onObject) { + $msg = sprintf( + $this->translate('There have been %d changes to this object, nothing else has been modified'), + $onObject + ); + } else { + $msg = sprintf( + $this->translate('There are %d pending changes, %d of them applied to this object'), + $total, + $onObject + ); + } + + $this->setAttrib('class', 'gipfl-inline-form'); + $this->addHtml(Icon::create('wrench')); + try { + // As this is shown for single objects, ignore errors caused by an + // unreachable core + $target = $this->shouldWarnAboutBug7530() ? '_self' : '_next'; + } catch (\Exception $e) { + $target = '_next'; + } + $this->addSubmitButton($this->translate('Deploy'), [ + 'class' => 'link-button icon-wrench', + 'title' => $msg, + 'data-base-target' => $target, + ]); + } + + protected function canDeploy() + { + return $this->auth->hasPermission('director/deploy'); + } + + public function render(Zend_View_Interface $view = null) + { + if (! $this->canDeploy()) { + return ''; + } + + return parent::render($view); + } + + public function onSuccess() + { + try { + if ($this->skipBecauseOfBug7530()) { + return; + } + } catch (\Exception $e) { + // continue + } + $this->deploy(); + } + + public function deploy() + { + $this->setSuccessUrl('director/config/deployments'); + $config = IcingaConfig::generate($this->db); + $checksum = $config->getHexChecksum(); + + try { + $this->api->wipeInactiveStages($this->db); + } catch (\Exception $e) { + $this->notifyError($e->getMessage()); + } + + if ($this->api->dumpConfig($config, $this->db)) { + $this->deploymentSucceeded($checksum); + } else { + $this->deploymentFailed($checksum); + } + } + + protected function deploymentSucceeded($checksum) + { + if ($this->getRequest()->isApiRequest()) { + throw new IcingaException('Not yet'); + // $this->sendJson($this->getResponse(), (object) array('checksum' => $checksum)); + } else { + $msg = $this->translate('Config has been submitted, validation is going on'); + $this->redirectOnSuccess($msg); + } + } + + protected function deploymentFailed($checksum, $error = null) + { + $extra = $error ? ': ' . $error: ''; + + if ($this->getRequest()->isApiRequest()) { + throw new IcingaException('Not yet'); + // $this->sendJsonError($this->getResponse(), 'Config deployment failed' . $extra); + } else { + $msg = $this->translate('Config deployment failed') . $extra; + $this->notifyError($msg); + $this->redirectAndExit('director/config/deployments'); + } + } +} diff --git a/application/forms/DirectorDatafieldCategoryForm.php b/application/forms/DirectorDatafieldCategoryForm.php new file mode 100644 index 0000000..fe5efc9 --- /dev/null +++ b/application/forms/DirectorDatafieldCategoryForm.php @@ -0,0 +1,36 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class DirectorDatafieldCategoryForm extends DirectorObjectForm +{ + protected $objectName = 'Data field category'; + + protected $listUrl = 'director/data/fieldcategories'; + + public function setup() + { + $this->addHtmlHint( + $this->translate( + 'Data field categories allow to structure Data Fields. Fields with' + . ' a category will be shown grouped by category.' + ) + ); + + $this->addElement('text', 'category_name', [ + 'label' => $this->translate('Category name'), + 'description' => $this->translate( + 'The unique name of the category used for grouping your custom Data Fields.' + ), + 'required' => true, + ]); + + $this->addElement('text', 'description', [ + 'label' => $this->translate('Description'), + ]); + + $this->setButtons(); + } +} diff --git a/application/forms/DirectorDatafieldForm.php b/application/forms/DirectorDatafieldForm.php new file mode 100644 index 0000000..a306bd7 --- /dev/null +++ b/application/forms/DirectorDatafieldForm.php @@ -0,0 +1,301 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\Director\CustomVariable\CustomVariables; +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Application\Hook; +use Exception; + +class DirectorDatafieldForm extends DirectorObjectForm +{ + protected $objectName = 'Data field'; + + protected $listUrl = 'director/data/fields'; + + protected function onRequest() + { + if ($this->hasBeenSent()) { + if ($this->shouldBeDeleted()) { + $varname = $this->getSentValue('varname'); + if ($cnt = CustomVariables::countAll($varname, $this->getDb())) { + $this->askForVariableDeletion($varname, $cnt); + } + } elseif ($this->shouldBeRenamed()) { + $varname = $this->object()->getOriginalProperty('varname'); + if ($cnt = CustomVariables::countAll($varname, $this->getDb())) { + $this->askForVariableRename( + $varname, + $this->getSentValue('varname'), + $cnt + ); + } + } + } + + parent::onRequest(); + } + + protected function askForVariableDeletion($varname, $cnt) + { + $msg = $this->translate( + 'Leaving custom variables in place while removing the related field is' + . ' perfectly legal and might be a desired operation. This way you can' + . ' no longer modify related custom variables in the Director GUI, but' + . ' the variables themselves will stay there and continue to be deployed.' + . ' When you re-add a field for the same variable later on, everything' + . ' will continue to work as before' + ); + + $this->addBoolean('wipe_vars', array( + 'label' => $this->translate('Wipe related vars'), + 'description' => sprintf($msg, $this->getSentValue('varname')), + 'required' => true, + )); + + if ($wipe = $this->getSentValue('wipe_vars')) { + if ($wipe === 'y') { + CustomVariables::deleteAll($varname, $this->getDb()); + } + } else { + $this->abortDeletion(); + $this->addError( + sprintf( + $this->translate('Also wipe all "%s" custom variables from %d objects?'), + $varname, + $cnt + ) + ); + $this->getElement('wipe_vars')->addError( + sprintf( + $this->translate( + 'There are %d objects with a related property. Should I also' + . ' remove the "%s" property from them?' + ), + $cnt, + $varname + ) + ); + } + } + + protected function askForVariableRename($oldname, $newname, $cnt) + { + $msg = $this->translate( + 'Leaving custom variables in place while renaming the related field is' + . ' perfectly legal and might be a desired operation. This way you can' + . ' no longer modify related custom variables in the Director GUI, but' + . ' the variables themselves will stay there and continue to be deployed.' + . ' When you re-add a field for the same variable later on, everything' + . ' will continue to work as before' + ); + + $this->addBoolean('rename_vars', array( + 'label' => $this->translate('Rename related vars'), + 'description' => sprintf($msg, $this->getSentValue('varname')), + 'required' => true, + )); + + if ($wipe = $this->getSentValue('rename_vars')) { + if ($wipe === 'y') { + CustomVariables::renameAll($oldname, $newname, $this->getDb()); + } + } else { + $this->abortDeletion(); + $this->addError( + sprintf( + $this->translate('Also rename all "%s" custom variables to "%s" on %d objects?'), + $oldname, + $newname, + $cnt + ) + ); + $this->getElement('rename_vars')->addError( + sprintf( + $this->translate( + 'There are %d objects with a related property. Should I also' + . ' rename the "%s" property to "%s" on them?' + ), + $cnt, + $oldname, + $newname + ) + ); + } + } + + public function setup() + { + $this->addHtmlHint( + $this->translate( + 'Data fields allow you to customize input controls for Icinga custom' + . ' variables. Once you defined them here, you can provide them through' + . ' your defined templates. This gives you a granular control over what' + . ' properties your users should be allowed to configure in which way.' + ) + ); + + $this->addElement('text', 'varname', array( + 'label' => $this->translate('Field name'), + 'description' => $this->translate( + 'The unique name of the field. This will be the name of the custom' + . ' variable in the rendered Icinga configuration.' + ), + 'required' => true, + )); + + $this->addElement('text', 'caption', array( + 'label' => $this->translate('Caption'), + 'required' => true, + 'description' => $this->translate( + 'The caption which should be displayed to your users when this field' + . ' is shown' + ) + )); + + $this->addElement('textarea', 'description', array( + 'label' => $this->translate('Description'), + 'description' => $this->translate( + 'An extended description for this field. Will be shown as soon as a' + . ' user puts the focus on this field' + ), + 'rows' => '3', + )); + + $this->addElement('select', 'category_id', [ + 'label' => $this->translate('Data Field Category'), + 'multiOptions' => $this->optionalEnum($this->enumCategpories()), + ]); + + $error = false; + try { + $types = $this->enumDataTypes(); + } catch (Exception $e) { + $error = $e->getMessage(); + $types = $this->optionalEnum(array()); + } + + $this->addElement('select', 'datatype', array( + 'label' => $this->translate('Data type'), + 'description' => $this->translate('Field type'), + 'required' => true, + 'multiOptions' => $types, + 'class' => 'autosubmit', + )); + if ($error) { + $this->getElement('datatype')->addError($error); + } + + $object = $this->object(); + try { + if ($class = $this->getSentValue('datatype')) { + if ($class && array_key_exists($class, $types)) { + $this->addSettings($class); + } + } elseif ($class = $object->get('datatype')) { + $this->addSettings($class); + } + + // TODO: next line looks like obsolete duplicate code to me + $this->addSettings(); + } catch (Exception $e) { + $this->getElement('datatype')->addError($e->getMessage()); + } + + foreach ($object->getSettings() as $key => $val) { + if ($el = $this->getElement($key)) { + $el->setValue($val); + } + } + + $this->setButtons(); + } + + public function shouldBeRenamed() + { + $object = $this->object(); + return $object->hasBeenLoadedFromDb() + && $object->getOriginalProperty('varname') !== $this->getSentValue('varname'); + } + + protected function addSettings($class = null) + { + if ($class === null) { + $class = $this->getValue('datatype'); + } + + if ($class !== null) { + if (! class_exists($class)) { + throw new ConfigurationError( + 'The hooked class "%s" for this data field does no longer exist', + $class + ); + } + + $class::addSettingsFormFields($this); + } + } + + protected function clearOutdatedSettings() + { + $names = array(); + $object = $this->object(); + $global = array('varname', 'description', 'caption', 'datatype'); + + /** @var \Zend_Form_Element $el */ + foreach ($this->getElements() as $el) { + if ($el->getIgnore()) { + continue; + } + + $name = $el->getName(); + if (in_array($name, $global)) { + continue; + } + + $names[$name] = $name; + } + + + foreach ($object->getSettings() as $setting => $value) { + if (! array_key_exists($setting, $names)) { + unset($object->$setting); + } + } + } + + public function onSuccess() + { + $this->clearOutdatedSettings(); + + if ($class = $this->getValue('datatype')) { + if (array_key_exists($class, $this->enumDataTypes())) { + $this->addHidden('format', $class::getFormat()); + } + } + + parent::onSuccess(); + } + + protected function enumDataTypes() + { + $hooks = Hook::all('Director\\DataType'); + $enum = array(null => '- please choose -'); + /** @var DataTypeHook $hook */ + foreach ($hooks as $hook) { + $enum[get_class($hook)] = $hook->getName(); + } + + return $enum; + } + + protected function enumCategpories() + { + $db = $this->getDb()->getDbAdapter(); + return $db->fetchPairs( + $db->select()->from('director_datafield_category', ['id', 'category_name']) + ); + } +} diff --git a/application/forms/DirectorDatalistEntryForm.php b/application/forms/DirectorDatalistEntryForm.php new file mode 100644 index 0000000..c6e309f --- /dev/null +++ b/application/forms/DirectorDatalistEntryForm.php @@ -0,0 +1,80 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Application\Config; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorDatalist; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class DirectorDatalistEntryForm extends DirectorObjectForm +{ + /** @var DirectorDatalist */ + protected $datalist; + + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->addElement('text', 'entry_name', [ + 'label' => $this->translate('Key'), + 'required' => true, + 'description' => $this->translate( + 'Will be stored as a custom variable value when this entry' + . ' is chosen from the list' + ) + ]); + + $this->addElement('text', 'entry_value', [ + 'label' => $this->translate('Label'), + 'required' => true, + 'description' => $this->translate( + 'This will be the visible caption for this entry' + ) + ]); + + $rolesConfig = Config::app('roles', true); + $roles = []; + foreach ($rolesConfig as $name => $role) { + $roles[$name] = $name; + } + + $this->addElement('extensibleSet', 'allowed_roles', [ + 'label' => $this->translate('Allowed roles'), + 'required' => false, + 'multiOptions' => $roles, + 'description' => $this->translate( + 'Allow to use this entry only to users with one of these Icinga Web 2 roles' + ) + ]); + + $this->addHidden('list_id', $this->datalist->get('id')); + $this->addHidden('format', 'string'); + if (!$this->isNew()) { + $this->addHidden('entry_name', $this->object->get('entry_name')); + } + + $this->addSimpleDisplayGroup(['entry_name', 'entry_value', 'allowed_roles'], 'entry', [ + 'legend' => $this->isNew() + ? $this->translate('Add data list entry') + : $this->translate('Modify data list entry') + ]); + + $this->setButtons(); + } + + /** + * @param DirectorDatalist $list + * @return $this + */ + public function setList(DirectorDatalist $list) + { + $this->datalist = $list; + /** @var Db $db */ + $db = $list->getConnection(); + $this->setDb($db); + + return $this; + } +} diff --git a/application/forms/DirectorDatalistForm.php b/application/forms/DirectorDatalistForm.php new file mode 100644 index 0000000..91c0ea7 --- /dev/null +++ b/application/forms/DirectorDatalistForm.php @@ -0,0 +1,45 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Authentication\Auth; + +class DirectorDatalistForm extends DirectorObjectForm +{ + public function setup() + { + $this->addElement('text', 'list_name', array( + 'label' => $this->translate('List name'), + 'description' => $this->translate( + 'Data lists are mainly used as data providers for custom variables' + . ' presented as dropdown boxes boxes. You can manually manage' + . ' their entries here in place, but you could also create dedicated' + . ' sync rules after creating a new empty list. This would allow you' + . ' to keep your available choices in sync with external data providers' + ), + 'required' => true, + )); + $this->addSimpleDisplayGroup(array('list_name'), 'list', array( + 'legend' => $this->translate('Data list') + )); + + $this->setButtons(); + } + + public function onSuccess() + { + $this->object()->set('owner', self::username()); + parent::onSuccess(); + } + + protected static function username() + { + $auth = Auth::getInstance(); + if ($auth->isAuthenticated()) { + return $auth->getUser()->getUsername(); + } else { + return '<unknown>'; + } + } +} diff --git a/application/forms/DirectorJobForm.php b/application/forms/DirectorJobForm.php new file mode 100644 index 0000000..7ca998c --- /dev/null +++ b/application/forms/DirectorJobForm.php @@ -0,0 +1,141 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Data\Db\DbObjectWithSettings; +use Icinga\Module\Director\Hook\JobHook; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Web\Hook; + +class DirectorJobForm extends DirectorObjectForm +{ + public function setup() + { + $jobTypes = $this->enumJobTypes(); + + $this->addElement('select', 'job_class', array( + 'label' => $this->translate('Job Type'), + 'required' => true, + 'multiOptions' => $this->optionalEnum($jobTypes), + 'description' => $this->translate( + 'These are different available job types' + ), + 'class' => 'autosubmit' + )); + + if (! $jobClass = $this->getJobClass()) { + return; + } + + if ($desc = $jobClass::getDescription($this)) { + $this->addHtmlHint($desc); + } + + $this->addBoolean( + 'disabled', + array( + 'label' => $this->translate('Disabled'), + 'description' => $this->translate( + 'This allows to temporarily disable this job' + ) + ), + 'n' + ); + + $this->addElement('text', 'run_interval', array( + 'label' => $this->translate('Run interval'), + 'description' => $this->translate( + 'Execution interval for this job, in seconds' + ), + 'value' => $jobClass::getSuggestedRunInterval($this) + )); + + $periods = $this->db->enumTimeperiods(); + + if (!empty($periods)) { + $this->addElement( + 'select', + 'timeperiod_id', + array( + 'label' => $this->translate('Time period'), + 'description' => $this->translate( + 'The name of a time period within this job should be active.' + . ' Supports only simple time periods (weekday and multiple' + . ' time definitions)' + ), + 'multiOptions' => $this->optionalEnum($periods), + ) + ); + } + + $this->addElement('text', 'job_name', array( + 'label' => $this->translate('Job name'), + 'description' => $this->translate( + 'A short name identifying this job. Use something meaningful,' + . ' like "Import Puppet Hosts"' + ), + 'required' => true, + )); + + $this->addSettings(); + $this->setButtons(); + } + + public function getSentOrObjectSetting($name, $default = null) + { + if ($this->hasObject()) { + $value = $this->getSentValue($name); + if ($value === null) { + /** @var DbObjectWithSettings $object */ + $object = $this->getObject(); + return $object->getSetting($name, $default); + } else { + return $value; + } + } else { + return $this->getSentValue($name, $default); + } + } + + protected function getJobClass($class = null) + { + if ($class === null) { + $class = $this->getSentOrObjectValue('job_class'); + } + + if (array_key_exists($class, $this->enumJobTypes())) { + return $class; + } + + return null; + } + + protected function addSettings($class = null) + { + if (! $class = $this->getJobClass($class)) { + return; + } + + $class::addSettingsFormFields($this); + foreach ($this->object()->getSettings() as $key => $val) { + if ($el = $this->getElement($key)) { + $el->setValue($val); + } + } + } + + protected function enumJobTypes() + { + /** @var JobHook[] $hooks */ + $hooks = Hook::all('Director\\Job'); + + $enum = array(); + + foreach ($hooks as $hook) { + $enum[get_class($hook)] = $hook->getName(); + } + asort($enum); + + return $enum; + } +} diff --git a/application/forms/IcingaAddServiceForm.php b/application/forms/IcingaAddServiceForm.php new file mode 100644 index 0000000..df2302e --- /dev/null +++ b/application/forms/IcingaAddServiceForm.php @@ -0,0 +1,183 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use gipfl\IcingaWeb2\Link; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaService; + +class IcingaAddServiceForm extends DirectorObjectForm +{ + /** @var IcingaHost[] */ + private $hosts; + + /** @var IcingaHost */ + private $host; + + /** @var IcingaService */ + protected $object; + + protected $objectName = 'service'; + + public function setup() + { + if ($this->object === null) { + $this->object = IcingaService::create( + ['object_type' => 'object'], + $this->db + ); + } + + $this->addSingleImportElement(); + + if (! ($imports = $this->getSentOrObjectValue('imports'))) { + $this->setSubmitLabel($this->translate('Next')); + $this->groupMainProperties(); + return; + } + + $this->removeElement('imports'); + $this->addHidden('imports', $imports); + $this->setElementValue('imports', $imports); + $this->addNameElement(); + $name = $this->getSentOrObjectValue('object_name'); + if (empty($name)) { + $this->setElementValue('object_name', $imports); + } + $this->groupMainProperties() + ->setButtons(); + } + + protected function groupMainProperties($importsFirst = false) + { + $elements = [ + 'object_type', + 'imports', + 'object_name', + ]; + + $this->addDisplayGroup($elements, 'object_definition', [ + 'decorators' => [ + 'FormElements', + ['HtmlTag', ['tag' => 'dl']], + 'Fieldset', + ], + 'order' => self::GROUP_ORDER_OBJECT_DEFINITION, + 'legend' => $this->translate('Main properties') + ]); + + return $this; + } + + /** + * @param bool $required + * @return $this + */ + protected function addSingleImportElement($required = null) + { + $enum = $this->enumServiceTemplates(); + if (empty($enum)) { + if ($required) { + if ($this->hasBeenSent()) { + $this->addError($this->translate('No service has been chosen')); + } else { + if ($this->hasPermission('director/admin')) { + $html = sprintf( + $this->translate('Please define a %s first'), + Link::create( + $this->translate('Service Template'), + 'director/service/add', + ['type' => 'template'] + ) + ); + } else { + $html = $this->translate('No Service Templates have been provided yet'); + } + $this->addHtml('<p class="warning">' . $html . '</p>'); + } + } + + return $this; + } + $this->addElement('text', 'imports', [ + 'label' => $this->translate('Service'), + 'description' => $this->translate('Choose a service template'), + 'required' => true, + 'data-suggestion-context' => 'servicetemplates', + 'class' => 'autosubmit director-suggest' + ]); + + return $this; + } + + protected function enumServiceTemplates() + { + $tpl = $this->getDb()->enumIcingaTemplates('service'); + return array_combine($tpl, $tpl); + } + + /** + * @param IcingaHost[] $hosts + * @return $this + */ + public function setHosts(array $hosts) + { + $this->hosts = $hosts; + return $this; + } + + /** + * @param IcingaHost $host + * @return $this + */ + public function setHost(IcingaHost $host) + { + $this->host = $host; + return $this; + } + + protected function addNameElement() + { + $this->addElement('text', 'object_name', [ + 'label' => $this->translate('Name'), + 'required' => true, + 'description' => $this->translate( + 'Name for the Icinga service you are going to create' + ) + ]); + + return $this; + } + + public function onSuccess() + { + if ($this->host !== null) { + if ($id = $this->host->get('id')) { + $this->object->set('host_id', $id); + } else { + $this->object->set('host', $this->host->getObjectName()); + } + parent::onSuccess(); + return; + } + + $plain = $this->object->toPlainObject(); + $db = $this->object->getConnection(); + + // TODO: Test this: + foreach ($this->hosts as $host) { + $service = IcingaService::fromPlainObject($plain, $db) + ->set('host_id', $host->get('id')); + $this->getDbObjectStore()->store($service); + } + + $msg = sprintf( + $this->translate('The service "%s" has been added to %d hosts'), + $this->object->getObjectName(), + count($this->hosts) + ); + + $this->redirectOnSuccess($msg); + } +} diff --git a/application/forms/IcingaAddServiceSetForm.php b/application/forms/IcingaAddServiceSetForm.php new file mode 100644 index 0000000..b889110 --- /dev/null +++ b/application/forms/IcingaAddServiceSetForm.php @@ -0,0 +1,123 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaAddServiceSetForm extends DirectorObjectForm +{ + /** @var IcingaHost[] */ + private $hosts; + + /** @var IcingaHost */ + private $host; + + /** @var IcingaServiceSet */ + protected $object; + + protected $objectName = 'service_set'; + + protected $listUrl = 'director/services/sets'; + + public function setup() + { + if ($this->object === null) { + $this->object = IcingaServiceSet::create( + ['object_type' => 'object'], + $this->db + ); + } + + $object = $this->object(); + if ($this->hasBeenSent()) { + $object->set('object_name', $this->getSentValue('imports')); + $object->set('imports', $object->getObjectName()); + } + + if (! $object->hasBeenLoadedFromDb()) { + $this->addSingleImportsElement(); + } + + if (count($object->get('imports'))) { + $description = $object->getResolvedProperty('description'); + if ($description) { + $this->addHtmlHint($description); + } + } + + $this->addHidden('object_type', 'object'); + $this->setButtons(); + } + + protected function setObjectSuccessUrl() + { + if ($this->host) { + $this->setSuccessUrl( + 'director/host/services', + array('name' => $this->host->getObjectName()) + ); + } else { + parent::setObjectSuccessUrl(); + } + } + + public function setHost(IcingaHost $host) + { + $this->host = $host; + return $this; + } + /** + * @param IcingaHost[] $hosts + * @return $this + */ + public function setHosts(array $hosts) + { + $this->hosts = $hosts; + return $this; + } + + protected function addSingleImportsElement() + { + $enum = $this->enumAllowedTemplates(); + + $this->addElement('select', 'imports', array( + 'label' => $this->translate('Service set'), + 'description' => $this->translate( + 'The service Set that should be assigned' + ), + 'required' => true, + 'multiOptions' => $this->optionallyAddFromEnum($enum), + 'class' => 'autosubmit' + )); + + return $this; + } + + public function onSuccess() + { + if ($this->host !== null) { + $this->object->set('host_id', $this->host->get('id')); + parent::onSuccess(); + return; + } + + $plain = $this->object->toPlainObject(); + $db = $this->object->getConnection(); + + foreach ($this->hosts as $host) { + IcingaServiceSet::fromPlainObject($plain, $db) + ->set('host_id', $host->get('id')) + ->store(); + } + + $msg = sprintf( + $this->translate('The Service Set "%s" has been added to %d hosts'), + $this->object->getObjectName(), + count($this->hosts) + ); + + $this->redirectOnSuccess($msg); + } +} diff --git a/application/forms/IcingaApiUserForm.php b/application/forms/IcingaApiUserForm.php new file mode 100644 index 0000000..eda0857 --- /dev/null +++ b/application/forms/IcingaApiUserForm.php @@ -0,0 +1,25 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaApiUserForm extends DirectorObjectForm +{ + public function setup() + { + $this->addHidden('object_type', 'external_object'); + + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Name'), + 'required' => true, + )); + + $this->addElement('password', 'password', array( + 'label' => $this->translate('Password'), + 'required' => true, + )); + + $this->setButtons(); + } +} diff --git a/application/forms/IcingaCloneObjectForm.php b/application/forms/IcingaCloneObjectForm.php new file mode 100644 index 0000000..6ee99ba --- /dev/null +++ b/application/forms/IcingaCloneObjectForm.php @@ -0,0 +1,259 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use gipfl\Web\Widget\Hint; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Acl; +use Icinga\Module\Director\Data\Db\DbObjectStore; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class IcingaCloneObjectForm extends DirectorForm +{ + /** @var IcingaObject */ + protected $object; + + protected $baseObjectUrl; + + /** @var Branch */ + protected $branch; + + public function setup() + { + $isBranch = $this->branch && $this->branch->isBranch(); + $branchOnly = $this->object->get('id') === null; + if ($isBranch && $this->object instanceof IcingaObject && $this->object->isTemplate()) { + $this->addHtml(Hint::error($this->translate( + 'Templates cannot be cloned in Configuration Branches' + ))); + $this->submitLabel = false; + return; + } + $name = $this->object->getObjectName(); + $this->addElement('text', 'new_object_name', array( + 'label' => $this->translate('New name'), + 'required' => true, + 'value' => $name, + )); + + if (!$branchOnly && Acl::instance()->hasPermission('director/admin')) { + $this->addElement('select', 'clone_type', array( + 'label' => 'Clone type', + 'required' => true, + 'multiOptions' => array( + 'equal' => $this->translate('Clone the object as is, preserving imports'), + 'flat' => $this->translate('Flatten all inherited properties, strip imports'), + ) + )); + } + + if (!$branchOnly && ($this->object instanceof IcingaHost + || $this->object instanceof IcingaServiceSet) + ) { + $this->addBoolean('clone_services', [ + 'label' => $this->translate('Clone Services'), + 'description' => $this->translate( + 'Also clone single Services defined for this Host' + ) + ], 'y'); + } + + if (!$branchOnly && $this->object instanceof IcingaHost) { + $this->addBoolean('clone_service_sets', [ + 'label' => $this->translate('Clone Service Sets'), + 'description' => $this->translate( + 'Also clone single Service Sets defined for this Host' + ) + ], 'y'); + } + + if ($this->object instanceof IcingaService) { + if ($this->object->get('service_set_id') !== null) { + $this->addElement('select', 'target_service_set', [ + 'label' => $this->translate('Target Service Set'), + 'description' => $this->translate( + 'Clone this service to the very same or to another Service Set' + ), + 'multiOptions' => $this->enumServiceSets(), + 'value' => $this->object->get('service_set_id') + ]); + } elseif ($this->object->get('host_id') !== null) { + $this->addElement('text', 'target_host', [ + 'label' => $this->translate('Target Host'), + 'description' => $this->translate( + 'Clone this service to the very same or to another Host' + ), + 'value' => $this->object->get('host'), + 'class' => "autosubmit director-suggest", + 'data-suggestion-context' => 'HostsAndTemplates', + ]); + } + } + + if ($this->object->isTemplate() && $this->object->supportsFields()) { + $this->addBoolean('clone_fields', [ + 'label' => $this->translate('Clone Template Fields'), + 'description' => $this->translate( + 'Also clone fields provided by this Template' + ) + ], 'y'); + } + + $this->submitLabel = sprintf( + $this->translate('Clone "%s"'), + $name + ); + } + + public function setBranch(Branch $branch) + { + $this->branch = $branch; + + return $this; + } + + public function setObjectBaseUrl($url) + { + $this->baseObjectUrl = $url; + + return $this; + } + + public function onSuccess() + { + $object = $this->object; + $table = $object->getTableName(); + $type = $object->getShortTableName(); + $connection = $object->getConnection(); + $db = $connection->getDbAdapter(); + $newName = $this->getValue('new_object_name'); + $resolve = Acl::instance()->hasPermission('director/admin') + && $this->getValue('clone_type') === 'flat'; + + $msg = sprintf( + 'The %s "%s" has been cloned from "%s"', + $type, + $newName, + $object->getObjectName() + ); + + if ($object->isTemplate() && $this->branch && $this->branch->isBranch()) { + throw new IcingaException('Cloning templates is not available for Branches'); + } + + if ($object->isTemplate() && $object->getObjectName() === $newName) { + throw new IcingaException( + $this->translate('Name needs to be changed when cloning a Template') + ); + } + + $new = $object::fromPlainObject( + $object->toPlainObject($resolve), + $connection + )->set('object_name', $newName); + + if ($new->isExternal()) { + $new->set('object_type', 'object'); + } + + if ($set = $this->getValue('target_service_set')) { + $new->set( + 'service_set_id', + IcingaServiceSet::loadWithAutoIncId((int) $set, $connection)->get('id') + ); + } elseif ($host = $this->getValue('target_host')) { + $new->set('host', $host); + } + + $services = []; + $sets = []; + if ($object instanceof IcingaHost) { + $new->set('api_key', null); + if ($this->getValue('clone_services') === 'y') { + $services = $object->fetchServices(); + } + if ($this->getValue('clone_service_sets') === 'y') { + $sets = $object->fetchServiceSets(); + } + } elseif ($object instanceof IcingaServiceSet) { + if ($this->getValue('clone_services') === 'y') { + $services = $object->fetchServices(); + } + } + if ($this->getValue('clone_fields') === 'y') { + $fields = $db->fetchAll( + $db->select() + ->from($table . '_field') + ->where("${type}_id = ?", $object->get('id')) + ); + } else { + $fields = []; + } + + $store = new DbObjectStore($connection, $this->branch); + if ($store->store($new)) { + $newId = $new->get('id'); + foreach ($services as $service) { + $clone = IcingaService::fromPlainObject( + $service->toPlainObject(), + $connection + ); + + if ($new instanceof IcingaHost) { + $clone->set('host_id', $newId); + } elseif ($new instanceof IcingaServiceSet) { + $clone->set('service_set_id', $newId); + } + $store->store($clone); + } + + foreach ($sets as $set) { + $newSet = IcingaServiceSet::fromPlainObject( + $set->toPlainObject(), + $connection + )->set('host_id', $newId); + $store->store($newSet); + } + + foreach ($fields as $row) { + $row->{"${type}_id"} = $newId; + $db->insert($table . '_field', (array) $row); + } + + if ($new instanceof IcingaServiceSet) { + $this->setSuccessUrl( + 'director/serviceset', + $new->getUrlParams() + ); + } else { + $this->setSuccessUrl( + $this->baseObjectUrl ?: 'director/' . strtolower($type), + $new->getUrlParams() + ); + } + + $this->redirectOnSuccess($msg); + } + } + + protected function enumServiceSets() + { + $db = $this->object->getConnection()->getDbAdapter(); + return $db->fetchPairs( + $db->select() + ->from('icinga_service_set', ['id', 'object_name']) + ->order('object_name') + ); + } + + public function setObject(IcingaObject $object) + { + $this->object = $object; + return $this; + } +} diff --git a/application/forms/IcingaCommandArgumentForm.php b/application/forms/IcingaCommandArgumentForm.php new file mode 100644 index 0000000..5dbef41 --- /dev/null +++ b/application/forms/IcingaCommandArgumentForm.php @@ -0,0 +1,190 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaCommandArgumentForm extends DirectorObjectForm +{ + /** @var IcingaCommand */ + protected $commandObject; + + public function setCommandObject(IcingaCommand $object) + { + $this->commandObject = $object; + $this->setDb($object->getConnection()); + return $this; + } + + public function setup() + { + $this->addHidden('command_id', $this->commandObject->get('id')); + + $this->addElement('text', 'argument_name', array( + 'label' => $this->translate('Argument name'), + 'filters' => array('StringTrim'), + 'description' => $this->translate('e.g. -H or --hostname, empty means "skip_key"') + )); + + $this->addElement('text', 'description', array( + 'label' => $this->translate('Description'), + 'description' => $this->translate('Description of the argument') + )); + + $this->addElement('select', 'argument_format', array( + 'label' => $this->translate('Value type'), + 'multiOptions' => array( + 'string' => $this->translate('String'), + 'expression' => $this->translate('Icinga DSL') + ), + 'description' => $this->translate( + 'Whether the argument value is a string (allowing macros like $host$)' + . ' or an Icinga DSL lambda function (will be enclosed with {{ ... }}' + ), + 'class' => 'autosubmit', + )); + + if ($this->getSentOrObjectValue('argument_format') === 'expression') { + $this->addElement('textarea', 'argument_value', array( + 'label' => $this->translate('Value'), + 'description' => $this->translate( + 'An Icinga DSL expression, e.g.: var cmd = macro("$cmd$");' + . ' return typeof(command) == String ...' + ), + 'rows' => 3 + )); + } else { + $this->addElement('text', 'argument_value', array( + 'label' => $this->translate('Value'), + 'description' => $this->translate( + 'e.g. 5%, $host.name$, $lower$%:$upper$%' + ) + )); + } + + $this->addElement('text', 'sort_order', array( + 'label' => $this->translate('Position'), + 'description' => $this->translate( + 'Leave empty for non-positional arguments. Can be a positive or' + . ' negative number and influences argument ordering' + ) + )); + + $this->addElement('select', 'set_if_format', array( + 'label' => $this->translate('Condition format'), + 'multiOptions' => array( + 'string' => $this->translate('String'), + 'expression' => $this->translate('Icinga DSL') + ), + 'description' => $this->translate( + 'Whether the set_if parameter is a string (allowing macros like $host$)' + . ' or an Icinga DSL lambda function (will be enclosed with {{ ... }}' + ), + 'class' => 'autosubmit', + )); + + if ($this->getSentOrObjectValue('set_if_format') === 'expression') { + $this->addElement('textarea', 'set_if', array( + 'label' => $this->translate('Condition (set_if)'), + 'description' => $this->translate( + 'An Icinga DSL expression that returns a boolean value, e.g.: var cmd = bool(macro("$cmd$"));' + . ' return cmd ...' + ), + 'rows' => 3 + )); + } else { + $this->addElement('text', 'set_if', array( + 'label' => $this->translate('Condition (set_if)'), + 'description' => $this->translate( + 'Only set this parameter if the argument value resolves to a' + . ' numeric value. String values are not supported' + ) + )); + } + + $this->addBoolean('repeat_key', array( + 'label' => $this->translate('Repeat key'), + 'description' => $this->translate( + 'Whether this parameter should be repeated when multiple values' + . ' (read: array) are given' + ) + )); + + $this->addBoolean('skip_key', array( + 'label' => $this->translate('Skip key'), + 'description' => $this->translate( + 'Whether the parameter name should not be passed to the command.' + . ' Per default, the parameter name (e.g. -H) will be appended,' + . ' so no need to explicitly set this to "No".' + ) + )); + + $this->addBoolean('required', array( + 'label' => $this->translate('Required'), + 'required' => false, + 'description' => $this->translate('Whether this argument should be required') + )); + + $this->setButtons(); + } + + protected function deleteObject($object) + { + $cmd = $this->commandObject; + + $msg = sprintf( + $this->translate('%s argument "%s" has been removed'), + $this->translate($this->getObjectShortClassName()), + $object->argument_name + ); + + // TODO: remove argument_id, once verified that it is no longer in use + $url = $this->getSuccessUrl()->without('argument_id')->without('argument'); + + $cmd->arguments()->remove($object->argument_name); + if ($this->branch->isBranch()) { + $this->getDbObjectStore()->store($cmd); + $this->setSuccessUrl($url); + } else { + if ($cmd->store()) { + $this->setSuccessUrl($url); + } + } + + $this->redirectOnSuccess($msg); + } + + public function onSuccess() + { + $object = $this->object(); + $cmd = $this->commandObject; + if ($object->get('argument_name') === null) { + $object->set('skip_key', true); + $object->set('argument_name', $cmd->getNextSkippableKeyName()); + } + + if ($object->hasBeenModified()) { + $cmd->arguments()->set( + $object->get('argument_name'), + $object + ); + $msg = sprintf( + $this->translate('The argument %s has successfully been stored'), + $object->get('argument_name') + ); + $this->getDbObjectStore()->store($cmd); + } else { + if ($this->isApiRequest()) { + $this->setHttpResponseCode(304); + } + $msg = $this->translate('No action taken, object has not been modified'); + } + $this->setSuccessUrl('director/command/arguments', [ + 'argument' => $object->get('argument_name'), + 'name' => $cmd->getObjectName() + ]); + + $this->redirectOnSuccess($msg); + } +} diff --git a/application/forms/IcingaCommandForm.php b/application/forms/IcingaCommandForm.php new file mode 100644 index 0000000..ba1386b --- /dev/null +++ b/application/forms/IcingaCommandForm.php @@ -0,0 +1,137 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaCommandForm extends DirectorObjectForm +{ + public function setup() + { + $this->addObjectTypeElement(); + if (! $this->hasObjectType()) { + return; + } + + $this->addElement('select', 'methods_execute', array( + 'label' => $this->translate('Command type'), + 'multiOptions' => array( + null => '- please choose -', + $this->translate('Plugin commands') => array( + 'PluginCheck' => 'Plugin Check Command', + 'PluginNotification' => 'Notification Plugin Command', + 'PluginEvent' => 'Event Plugin Command', + ), + $this->translate('Internal commands') => array( + 'IcingaCheck' => 'Icinga Check Command', + 'ClusterCheck' => 'Icinga Cluster Check Command', + 'ClusterZoneCheck' => 'Icinga Cluster Zone Check Command', + 'IdoCheck' => 'Ido Check Command', + 'RandomCheck' => 'Random Check Command', + ) + ), + 'required' => ! $this->isTemplate(), + 'description' => $this->translate( + 'Plugin Check commands are what you need when running checks agains' + . ' your infrastructure. Notification commands will be used when it' + . ' comes to notify your users. Event commands allow you to trigger' + . ' specific actions when problems occur. Some people use them for' + . ' auto-healing mechanisms, like restarting services or rebooting' + . ' systems at specific thresholds' + ), + 'class' => 'autosubmit' + )); + + $nameLabel = $this->isTemplate() + ? $this->translate('Name') + : $this->translate('Command name'); + + $this->addElement('text', 'object_name', array( + 'label' => $nameLabel, + 'required' => true, + 'description' => $this->translate('Identifier for the Icinga command you are going to create') + )); + + $this->addImportsElement(false); + + $this->addElement('text', 'command', array( + 'label' => $this->translate('Command'), + 'required' => ! $this->isTemplate(), + 'description' => $this->translate( + 'The command Icinga should run. Absolute paths are accepted as provided,' + . ' relative paths are prefixed with "PluginDir + ", similar Constant prefixes are allowed.' + . ' Spaces will lead to separation of command path and standalone arguments. Please note that' + . ' this means that we do not support spaces in plugin names and paths right now.' + ) + )); + + $this->addElement('text', 'timeout', array( + 'label' => $this->translate('Timeout'), + 'description' => $this->translate( + 'Optional command timeout. Allowed values are seconds or durations postfixed with a' + . ' specific unit (e.g. 1m or also 3m 30s).' + ) + )); + + $descIsString = [ + $this->translate('Render the command as a plain string instead of an array.'), + $this->translate('If enabled you can not define arguments.'), + $this->translate('Disabled by default, and should only be used in rare cases.'), + $this->translate('WARNING, this can allow shell script injection via custom variables used in command.'), + ]; + + $this->addBoolean( + 'is_string', + array( + 'label' => $this->translate('Render as string'), + 'description' => join(' ', $descIsString), + ) + ); + + $this->addDisabledElement(); + $this->addZoneSection(); + $this->setButtons(); + } + + protected function addZoneSection() + { + $this->addZoneElement(true); + + $elements = array( + 'zone_id', + ); + $this->addDisplayGroup($elements, 'clustering', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_CLUSTERING, + 'legend' => $this->translate('Zone settings') + )); + + return $this; + } + + protected function enumAllowedTemplates() + { + $object = $this->object(); + $tpl = $this->db->enum($object->getTableName()); + if (empty($tpl)) { + return array(); + } + + $id = $object->get('id'); + + if (array_key_exists($id, $tpl)) { + unset($tpl[$id]); + } + + if (empty($tpl)) { + return array(); + } + + $tpl = array_combine($tpl, $tpl); + return $tpl; + } +} diff --git a/application/forms/IcingaDeleteObjectForm.php b/application/forms/IcingaDeleteObjectForm.php new file mode 100644 index 0000000..409bdc3 --- /dev/null +++ b/application/forms/IcingaDeleteObjectForm.php @@ -0,0 +1,41 @@ +<?php + +// TODO: Check whether this can be removed +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Form\QuickForm; + +class IcingaDeleteObjectForm extends QuickForm +{ + /** @var IcingaObject */ + protected $object; + + public function setup() + { + $this->submitLabel = sprintf( + $this->translate('YES, please delete "%s"'), + $this->object->getObjectName() + ); + } + + public function onSuccess() + { + $object = $this->object; + $msg = sprintf( + 'The %s "%s" has been deleted', + $object->getShortTableName(), + $object->getObjectName() + ); + + if ($object->delete()) { + $this->redirectOnSuccess($msg); + } + } + + public function setObject(IcingaObject $object) + { + $this->object = $object; + return $this; + } +} diff --git a/application/forms/IcingaDependencyForm.php b/application/forms/IcingaDependencyForm.php new file mode 100644 index 0000000..ab30844 --- /dev/null +++ b/application/forms/IcingaDependencyForm.php @@ -0,0 +1,309 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Objects\IcingaDependency; + +class IcingaDependencyForm extends DirectorObjectForm +{ + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->setupDependencyElements(); + } + + /*** + * @throws \Zend_Form_Exception + */ + protected function setupDependencyElements() + { + $this->addObjectTypeElement(); + if (! $this->hasObjectType()) { + $this->groupMainProperties(); + return; + } + + $this->addNameElement() + ->addDisabledElement() + ->addImportsElement() + ->addObjectsElement() + ->addBooleanElements() + ->addPeriodElement() + ->addAssignmentElements() + ->addEventFilterElements(['states']) + ->groupMainProperties() + ->addZoneSection() + ->setButtons(); + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addZoneSection() + { + $this->addZoneElement(true); + + $elements = array( + 'zone_id', + ); + $this->addDisplayGroup($elements, 'clustering', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_CLUSTERING, + 'legend' => $this->translate('Zone settings') + )); + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addNameElement() + { + $this->addElement('text', 'object_name', [ + 'label' => $this->translate('Name'), + 'required' => true, + 'description' => $this->translate('Name for the Icinga dependency you are going to create') + ]); + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addAssignmentElements() + { + if (!$this->object || !$this->object->isApplyRule()) { + return $this; + } + + $this->addElement('select', 'apply_to', [ + 'label' => $this->translate('Apply to'), + 'description' => $this->translate( + 'Whether this dependency should affect hosts or services' + ), + 'required' => true, + 'class' => 'autosubmit', + 'multiOptions' => $this->optionalEnum([ + 'host' => $this->translate('Hosts'), + 'service' => $this->translate('Services'), + ]) + ]); + + $applyTo = $this->getSentOrObjectValue('apply_to'); + + if (! $applyTo) { + return $this; + } + + $suggestionContext = ucfirst($applyTo) . 'FilterColumns'; + $this->addAssignFilter([ + 'suggestionContext' => $suggestionContext, + 'description' => $this->translate( + 'This allows you to configure an assignment filter. Please feel' + . ' free to combine as many nested operators as you want. The' + . ' "contains" operator is valid for arrays only. Please use' + . ' wildcards and the = (equals) operator when searching for' + . ' partial string matches, like in *.example.com' + ) + ]); + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addPeriodElement() + { + $periods = $this->db->enumTimeperiods(); + if (empty($periods)) { + return $this; + } + + $this->addElement( + 'select', + 'period_id', + array( + 'label' => $this->translate('Time period'), + 'description' => $this->translate( + 'The name of a time period which determines when this' + . ' notification should be triggered. Not set by default.' + ), + 'multiOptions' => $this->optionalEnum($periods), + ) + ); + + return $this; + } + + /** + * @return $this + */ + protected function addBooleanElements() + { + $this->addBoolean('disable_checks', [ + 'label' => $this->translate('Disable Checks'), + 'description' => $this->translate( + 'Whether to disable checks when this dependency fails.' + . ' Defaults to false.' + ) + ], null); + + $this->addBoolean('disable_notifications', [ + 'label' => $this->translate('Disable Notificiations'), + 'description' => $this->translate( + 'Whether to disable notifications when this dependency fails.' + . ' Defaults to true.' + ) + ], null); + + $this->addBoolean('ignore_soft_states', [ + 'label' => $this->translate('Ignore Soft States'), + 'description' => $this->translate( + 'Whether to ignore soft states for the reachability calculation.' + . ' Defaults to true.' + ) + ], null); + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addObjectsElement() + { + $dependency = $this->getObject(); + $parentHost = $dependency->get('parent_host'); + if ($parentHost === null) { + $parentHostVar = $dependency->get('parent_host_var'); + if (\strlen($parentHostVar) > 0) { + $parentHost = '$' . $dependency->get('parent_host_var') . '$'; + } + } + $this->addElement('text', 'parent_host', [ + 'label' => $this->translate('Parent Host'), + 'description' => $this->translate( + 'The parent host. You might want to refer Host Custom Variables' + . ' via $host.vars.varname$' + ), + 'class' => "autosubmit director-suggest", + 'data-suggestion-context' => 'hostnames', + 'order' => 10, + 'required' => $this->isObject(), + 'value' => $parentHost + ]); + $sentParent = $this->getSentOrObjectValue('parent_host'); + + if (!empty($sentParent) || $dependency->isApplyRule()) { + $parentService = $dependency->get('parent_service'); + $this->addElement('text', 'parent_service', [ + 'label' => $this->translate('Parent Service'), + 'description' => $this->translate( + 'Optional. The parent service. If omitted this dependency' + . ' object is treated as host dependency.' + ), + 'class' => "autosubmit director-suggest", + 'data-suggestion-context' => 'servicenames', + 'data-suggestion-for-host' => $sentParent, + 'order' => 20, + 'value' => $parentService + ]); + } + + // If configuring Object, allow selection of child host and/or service, + // otherwise apply rules will determine child object. + if ($dependency->isObject()) { + $this->addElement('text', 'child_host', [ + 'label' => $this->translate('Child Host'), + 'description' => $this->translate('The child host.'), + 'value' => $dependency->get('child_host'), + 'order' => 30, + 'class' => 'autosubmit director-suggest', + 'required' => $this->isObject(), + 'data-suggestion-context' => 'hostnames', + ]); + + $sentChild = $this->getSentOrObjectValue('child_host'); + + if (!empty($sentChild)) { + $this->addElement('text', 'child_service', [ + 'label' => $this->translate('Child Service'), + 'description' => $this->translate( + 'Optional. The child service. If omitted this dependency' + . ' object is treated as host dependency.' + ), + 'class' => 'autosubmit director-suggest', + 'order' => 40, + 'value' => $this->getObject()->get('child_service'), + 'data-suggestion-context' => 'servicenames', + 'data-suggestion-for-host' => $sentChild, + ]); + } + } + + $elements = ['parent_host', 'child_host', 'parent_service', 'child_service']; + $this->addDisplayGroup($elements, 'related_objects', [ + 'decorators' => [ + 'FormElements', + ['HtmlTag', ['tag' => 'dl']], + 'Fieldset', + ], + 'order' => self::GROUP_ORDER_RELATED_OBJECTS, + 'legend' => $this->translate('Related Objects') + ]); + + return $this; + } + + /** + * Hint: this is unused. Why? + * + * @param IcingaDependency $dependency + * @return $this + */ + public function createApplyRuleFor(IcingaDependency $dependency) + { + $object = $this->object(); + $object->setImports($dependency->getObjectName()); + $object->set('object_type', 'apply'); + $object->set('object_name', $dependency->getObjectName()); + + return $this; + } + + protected function handleProperties(DbObject $object, &$values) + { + if ($this->hasBeenSent()) { + if (isset($values['parent_host']) + && $this->isCustomVar($values['parent_host']) + ) { + $values['parent_host_var'] = \trim($values['parent_host'], '$'); + $values['parent_host'] = ''; + } + } + + parent::handleProperties($object, $values); + } + + protected function isCustomVar($string) + { + return \preg_match('/^\$(?:host)\.vars\..+\$$/', $string); + // Eventually: return \preg_match('/^\$(?:host|service)\.vars\..+\$$/', $string); + } +} diff --git a/application/forms/IcingaEndpointForm.php b/application/forms/IcingaEndpointForm.php new file mode 100644 index 0000000..1c08cb4 --- /dev/null +++ b/application/forms/IcingaEndpointForm.php @@ -0,0 +1,61 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaEndpointForm extends DirectorObjectForm +{ + public function setup() + { + $this->addObjectTypeElement(); + if (! $this->hasObjectType()) { + return; + } + + if ($this->isTemplate()) { + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Endpoint template name'), + 'required' => true, + 'description' => $this->translate('Name for the Icinga endpoint template you are going to create') + )); + } else { + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Endpoint'), + 'required' => true, + 'description' => $this->translate('Name for the Icinga endpoint you are going to create') + )); + } + + $this->addElement('text', 'host', array( + 'label' => $this->translate('Endpoint address'), + 'description' => $this->translate('IP address / hostname of remote node') + )); + + $this->addElement('text', 'port', array( + 'label' => $this->translate('Port'), + 'description' => $this->translate('The port of the endpoint.'), + )); + + $this->addElement('text', 'log_duration', array( + 'label' => $this->translate('Log Duration'), + 'description' => $this->translate('The log duration time.') + )); + + $this->addElement('select', 'apiuser_id', array( + 'label' => $this->translate('API user'), + 'multiOptions' => $this->optionalEnum($this->db->enumApiUsers()) + )); + + $this->addZoneElement(); + + if ($this->object->hasBeenLoadedFromDb()) { + $imports = $this->object->get('imports'); + if ($imports !== null && count($imports) > 0) { + $this->addImportsElement(false); + } + } + + $this->setButtons(); + } +} diff --git a/application/forms/IcingaForgetApiKeyForm.php b/application/forms/IcingaForgetApiKeyForm.php new file mode 100644 index 0000000..d1f475c --- /dev/null +++ b/application/forms/IcingaForgetApiKeyForm.php @@ -0,0 +1,34 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class IcingaForgetApiKeyForm extends DirectorForm +{ + /** @var IcingaHost */ + protected $host; + + public function setHost(IcingaHost $host) + { + $this->host = $host; + return $this; + } + + public function setup() + { + $this->addStandaloneSubmitButton(sprintf( + $this->translate('Drop Self Service API key'), + $this->host->getObjectName() + )); + } + + public function onSuccess() + { + $this->host->set('api_key', null)->store(); + $this->redirectOnSuccess(sprintf($this->translate( + 'The Self Service API key for %s has been dropped' + ), $this->host->getObjectName())); + } +} diff --git a/application/forms/IcingaGenerateApiKeyForm.php b/application/forms/IcingaGenerateApiKeyForm.php new file mode 100644 index 0000000..18980f0 --- /dev/null +++ b/application/forms/IcingaGenerateApiKeyForm.php @@ -0,0 +1,42 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class IcingaGenerateApiKeyForm extends DirectorForm +{ + /** @var IcingaHost */ + protected $host; + + public function setHost(IcingaHost $host) + { + $this->host = $host; + return $this; + } + + public function setup() + { + if ($this->host->getProperty('api_key')) { + $label = $this->translate('Regenerate Self Service API key'); + } else { + $label = $this->translate('Generate Self Service API key'); + } + + $this->addStandaloneSubmitButton(sprintf( + $label, + $this->host->getObjectName() + )); + } + + public function onSuccess() + { + $host = $this->host; + $host->generateApiKey(); + $host->store(); + $this->redirectOnSuccess(sprintf($this->translate( + 'A new Self Service API key for %s has been generated' + ), $host->getObjectName())); + } +} diff --git a/application/forms/IcingaHostForm.php b/application/forms/IcingaHostForm.php new file mode 100644 index 0000000..ec71471 --- /dev/null +++ b/application/forms/IcingaHostForm.php @@ -0,0 +1,390 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Exception\AuthenticationException; +use Icinga\Module\Director\Repository\IcingaTemplateRepository; +use Icinga\Module\Director\Restriction\HostgroupRestriction; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; + +class IcingaHostForm extends DirectorObjectForm +{ + public function setup() + { + $this->addObjectTypeElement(); + if (! $this->hasObjectType()) { + $this->groupMainProperties(); + return; + } + + $simpleImports = $this->isNew() && ! $this->isTemplate(); + if ($simpleImports) { + if (!$this->addSingleImportElement(true)) { + $this->setSubmitLabel(false); + return; + } + + if (! ($imports = $this->getSentOrObjectValue('imports'))) { + $this->setSubmitLabel($this->translate('Next')); + $this->groupMainProperties(); + return; + } + } + + $nameLabel = $this->isTemplate() + ? $this->translate('Name') + : $this->translate('Hostname'); + + $this->addElement('text', 'object_name', array( + 'label' => $nameLabel, + 'required' => true, + 'spellcheck' => 'false', + 'description' => $this->translate( + 'Icinga object name for this host. This is usually a fully qualified host name' + . ' but it could basically be any kind of string. To make things easier for your' + . ' users we strongly suggest to use meaningful names for templates. E.g. "generic-host"' + . ' is ugly, "Standard Linux Server" is easier to understand' + ) + )); + + if (! $simpleImports) { + $this->addImportsElement(); + } + + $this->addChoices('host') + ->addDisplayNameElement() + ->addAddressElements() + ->addGroupsElement() + ->addDisabledElement() + ->groupMainProperties($simpleImports) + ->addCheckCommandElements() + ->addCheckExecutionElements() + ->addExtraInfoElements() + ->addClusteringElements() + ->setButtons(); + } + + /** + * @return $this + */ + protected function addClusteringElements() + { + $this->addZoneElement(); + $this->addBoolean('has_agent', [ + 'label' => $this->translate('Icinga2 Agent'), + 'description' => $this->translate( + 'Whether this host has the Icinga 2 Agent installed' + ), + 'class' => 'autosubmit', + ]); + + if ($this->getSentOrResolvedObjectValue('has_agent') === 'y') { + $this->addBoolean('master_should_connect', [ + 'label' => $this->translate('Establish connection'), + 'description' => $this->translate( + 'Whether the parent (master) node should actively try to connect to this agent' + ), + 'required' => true + ]); + $this->addBoolean('accept_config', [ + 'label' => $this->translate('Accepts config'), + 'description' => $this->translate('Whether the agent is configured to accept config'), + 'required' => true + ]); + + $this->addHidden('command_endpoint_id', null); + $this->setSentValue('command_endpoint_id', null); + + $settings = $this->object->getConnection()->settings(); + if ($settings->get('feature_custom_endpoint') === 'y' && ! $this->isTemplate()) { + $this->addElement('text', 'custom_endpoint_name', [ + 'label' => $this->translate('Custom Endpoint Name'), + 'description' => $this->translate( + 'Use a different name for the generated endpoint object than the host name' + . ' and add a custom variable to allow services setting the correct command endpoint.' + ), + ]); + } + } else { + if ($this->isTemplate()) { + $this->addElement('select', 'command_endpoint_id', [ + 'label' => $this->translate('Command endpoint'), + 'description' => $this->translate( + 'Setting a command endpoint allows you to force host checks' + . ' to be executed by a specific endpoint. Please carefully' + . ' study the related Icinga documentation before using this' + . ' feature' + ), + 'multiOptions' => $this->optionalEnum($this->enumEndpoints()) + ]); + } + + foreach (['master_should_connect', 'accept_config'] as $key) { + $this->addHidden($key, null); + $this->setSentValue($key, null); + } + } + + $elements = [ + 'zone_id', + 'has_agent', + 'master_should_connect', + 'accept_config', + 'command_endpoint_id', + 'custom_endpoint_name', + 'api_key', + ]; + $this->addDisplayGroup($elements, 'clustering', [ + 'decorators' => [ + 'FormElements', + ['HtmlTag', ['tag' => 'dl']], + 'Fieldset', + ], + 'order' => self::GROUP_ORDER_CLUSTERING, + 'legend' => $this->translate('Icinga Agent and zone settings') + ]); + + return $this; + } + + /** + * @param bool $required + * @return bool + */ + protected function addSingleImportElement($required = null) + { + $enum = $this->enumHostTemplates(); + if (empty($enum)) { + if ($required) { + if ($this->hasBeenSent()) { + $this->addError($this->translate('No Host template has been chosen')); + } else { + if ($this->hasPermission('director/admin')) { + $html = sprintf( + $this->translate('Please define a %s first'), + Link::create( + $this->translate('Host Template'), + 'director/host/add', + ['type' => 'template'] + ) + ); + } else { + $html = $this->translate('No Host Template has been provided yet'); + } + + $this->addHtml('<p class="warning">' . $html . '</p>'); + } + } + + return false; + } + + $this->addElement('select', 'imports', [ + 'label' => $this->translate('Host Template'), + 'description' => $this->translate( + 'Choose a Host Template' + ), + 'required' => true, + 'multiOptions' => $this->optionalEnum($enum), + 'class' => 'autosubmit' + ]); + + return true; + } + + protected function enumHostTemplates() + { + $tpl = IcingaTemplateRepository::instanceByType('host', $this->getDb()) + ->listAllowedTemplateNames(); + return array_combine($tpl, $tpl); + } + + /** + * @return $this + */ + protected function addGroupsElement() + { + if ($this->hasHostGroupRestriction() + && ! $this->getAuth()->hasPermission('director/groups-for-restricted-hosts') + ) { + return $this; + } + + $this->addElement('extensibleSet', 'groups', array( + 'label' => $this->translate('Groups'), + 'suggest' => 'hostgroupnames', + 'description' => $this->translate( + 'Hostgroups that should be directly assigned to this node. Hostgroups can be useful' + . ' for various reasons. You might assign service checks based on assigned hostgroup.' + . ' They are also often used as an instrument to enforce restricted views in Icinga Web 2.' + . ' Hostgroups can be directly assigned to single hosts or to host templates. You might' + . ' also want to consider assigning hostgroups using apply rules' + ) + )); + + $applied = $this->getAppliedGroups(); + if (! empty($applied)) { + $this->addElement('simpleNote', 'applied_groups', [ + 'label' => $this->translate('Applied groups'), + 'value' => $this->createHostgroupLinks($applied), + 'ignore' => true, + ]); + } + + $inherited = $this->getInheritedGroups(); + if (! empty($inherited)) { + /** @var BaseHtmlElement $links */ + $links = $this->createHostgroupLinks($inherited); + if (count($this->object()->getGroups())) { + $links->addAttributes(['class' => 'strike-links']); + /** @var BaseHtmlElement $link */ + foreach ($links->getContent() as $link) { + if ($link instanceof BaseHtmlElement) { + $link->addAttributes([ + 'title' => $this->translate( + 'Group has been inherited, but will be overridden' + . ' by locally assigned group(s)' + ) + ]); + } + } + } + $this->addElement('simpleNote', 'inherited_groups', [ + 'label' => $this->translate('Inherited groups'), + 'value' => $links, + 'ignore' => true, + ]); + } + + return $this; + } + + protected function strikeGroupLinks(BaseHtmlElement $links) + { + /** @var BaseHtmlElement $link */ + foreach ($links->getContent() as $link) { + $link->getAttributes()->add('style', 'text-decoration: strike'); + } + $links->add('aha'); + } + + protected function getInheritedGroups() + { + if ($this->hasObject()) { + return $this->object->listInheritedGroupNames(); + } else { + return []; + } + } + + protected function createHostgroupLinks($groups) + { + $links = []; + foreach ($groups as $name) { + if (! empty($links)) { + $links[] = ', '; + } + $links[] = Link::create( + $name, + 'director/hostgroup', + ['name' => $name], + ['data-base-target' => '_next'] + ); + } + + return Html::tag('span', [ + 'style' => 'line-height: 2.5em; padding-left: 0.5em' + ], $links); + } + + protected function getAppliedGroups() + { + if ($this->isNew()) { + return []; + } + + return $this->object()->getAppliedGroups(); + } + + protected function hasHostGroupRestriction() + { + return $this->getAuth()->getRestrictions('director/filter/hostgroups'); + } + + /** + * @return $this + */ + protected function addAddressElements() + { + if ($this->isTemplate()) { + return $this; + } + + $this->addElement('text', 'address', array( + 'label' => $this->translate('Host address'), + 'description' => $this->translate( + 'Host address. Usually an IPv4 address, but may be any kind of address' + . ' your check plugin is able to deal with' + ) + )); + + $this->addElement('text', 'address6', array( + 'label' => $this->translate('IPv6 address'), + 'description' => $this->translate('Usually your hosts main IPv6 address') + )); + + return $this; + } + + /** + * @return $this + */ + protected function addDisplayNameElement() + { + if ($this->isTemplate()) { + return $this; + } + + $this->addElement('text', 'display_name', array( + 'label' => $this->translate('Display name'), + 'spellcheck' => 'false', + 'description' => $this->translate( + 'Alternative name for this host. Might be a host alias or and kind' + . ' of string helping your users to identify this host' + ) + )); + + return $this; + } + + protected function enumEndpoints() + { + $db = $this->db->getDbAdapter(); + $select = $db->select()->from('icinga_endpoint', [ + 'id', + 'object_name' + ])->where( + 'object_type IN (?)', + ['object', 'external_object'] + )->order('object_name'); + + return $db->fetchPairs($select); + } + + public function onSuccess() + { + if ($this->hasHostGroupRestriction()) { + $restriction = new HostgroupRestriction($this->getDb(), $this->getAuth()); + if (! $restriction->allowsHost($this->object())) { + throw new AuthenticationException($this->translate( + 'Unable to store a host with the given properties because of insufficient permissions' + )); + } + } + + parent::onSuccess(); + } +} diff --git a/application/forms/IcingaHostGroupForm.php b/application/forms/IcingaHostGroupForm.php new file mode 100644 index 0000000..be48318 --- /dev/null +++ b/application/forms/IcingaHostGroupForm.php @@ -0,0 +1,40 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaHostGroupForm extends DirectorObjectForm +{ + public function setup() + { + $this->addHidden('object_type', 'object'); + + $this->addElement('text', 'object_name', [ + 'label' => $this->translate('Hostgroup'), + 'required' => true, + 'description' => $this->translate('Icinga object name for this host group') + ]); + + $this->addGroupDisplayNameElement() + ->addAssignmentElements() + ->setButtons(); + } + + protected function addAssignmentElements() + { + $this->addAssignFilter([ + 'suggestionContext' => 'HostFilterColumns', + 'required' => false, + 'description' => $this->translate( + 'This allows you to configure an assignment filter. Please feel' + . ' free to combine as many nested operators as you want. The' + . ' "contains" operator is valid for arrays only. Please use' + . ' wildcards and the = (equals) operator when searching for' + . ' partial string matches, like in *.example.com' + ) + ]); + + return $this; + } +} diff --git a/application/forms/IcingaHostSelfServiceForm.php b/application/forms/IcingaHostSelfServiceForm.php new file mode 100644 index 0000000..1e05b96 --- /dev/null +++ b/application/forms/IcingaHostSelfServiceForm.php @@ -0,0 +1,156 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Web\Form\DirectorForm; +use Icinga\Security\SecurityException; + +class IcingaHostSelfServiceForm extends DirectorForm +{ + /** @var string */ + private $hostApiKey; + + /** @var IcingaHost */ + private $template; + + private $hostName; + + public function setup() + { + if ($this->hostName === null) { + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Host name'), + 'required' => true, + 'value' => $this->hostName, + )); + } + $this->addElement('text', 'display_name', array( + 'label' => $this->translate('Alias'), + )); + + $this->addElement('text', 'address', array( + 'label' => $this->translate('Host address'), + 'description' => $this->translate( + 'Host address. Usually an IPv4 address, but may be any kind of address' + . ' your check plugin is able to deal with' + ) + )); + + $this->addElement('text', 'address6', array( + 'label' => $this->translate('IPv6 address'), + 'description' => $this->translate('Usually your hosts main IPv6 address') + )); + + if ($this->template === null) { + $this->addElement('text', 'key', array( + 'label' => $this->translate('API Key'), + 'ignore' => true, + 'required' => true, + )); + } + + $this->submitLabel = sprintf( + $this->translate('Register') + ); + } + + public function setHostName($name) + { + $this->hostName = $name; + $this->removeElement('object_name'); + return $this; + } + + public function loadTemplateWithApiKey($key) + { + $this->template = IcingaHost::loadWithApiKey($key, $this->getDb()); + if (! $this->template->isTemplate()) { + throw new NotFoundError('Got invalid API key "%s"', $key); + } + + if ($this->template->getResolvedProperty('has_agent') !== 'y') { + throw new NotFoundError( + 'Got valid API key "%s", but template is not for Agents', + $key + ); + } + + $this->removeElement('key'); + + return $this->template; + } + + public function listMissingRequiredFields() + { + $result = []; + foreach ($this->getElements() as $element) { + if (in_array('isEmpty', $element->getErrors())) { + $result[] = $element->getName(); + } + } + + return $result; + } + + public function isMissingRequiredFields() + { + return count($this->listMissingRequiredFields()) > 0; + } + + public function onSuccess() + { + $db = $this->getDb(); + if ($this->template === null) { + $this->loadTemplateWithApiKey($this->getValue('key')); + } + $name = $this->hostName ?: $this->getValue('object_name'); + if (IcingaHost::exists($name, $db)) { + $host = IcingaHost::load($name, $db); + if ($host->isTemplate()) { + throw new SecurityException( + 'You are not allowed to create "%s"', + $name + ); + } + + if (null !== $host->getProperty('api_key')) { + throw new SecurityException( + 'The host "%s" has already been registered', + $name + ); + } + + $propertyNames = ['display_name', 'address', 'address6']; + foreach ($propertyNames as $property) { + if (\strlen($value = $this->getValue($property)) > 0) { + $host->set($property, $value); + } + } + } else { + $host = IcingaHost::create(array_filter($this->getValues(), 'strlen'), $db); + $host->set('object_name', $name); + $host->set('object_type', 'object'); + $host->set('imports', [$this->template]); + } + + $key = $host->generateApiKey(); + $host->store($db); + $this->hostApiKey = $key; + } + + /** + * @return string|null + */ + public function getHostApiKey() + { + return $this->hostApiKey; + } + + public static function create(Db $db) + { + return static::load()->setDb($db); + } +} diff --git a/application/forms/IcingaHostVarForm.php b/application/forms/IcingaHostVarForm.php new file mode 100644 index 0000000..cb15bcb --- /dev/null +++ b/application/forms/IcingaHostVarForm.php @@ -0,0 +1,36 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +/** + * @deprecated + */ +class IcingaHostVarForm extends DirectorObjectForm +{ + public function setup() + { + $this->addElement('select', 'host_id', array( + 'label' => $this->translate('Host'), + 'description' => $this->translate('The name of the host'), + 'multiOptions' => $this->optionalEnum($this->db->enumHosts()), + 'required' => true + )); + + $this->addElement('text', 'varname', array( + 'label' => $this->translate('Name'), + 'description' => $this->translate('host var name') + )); + + $this->addElement('textarea', 'varvalue', array( + 'label' => $this->translate('Value'), + 'description' => $this->translate('host var value') + )); + + $this->addElement('text', 'format', array( + 'label' => $this->translate('Format'), + 'description' => $this->translate('value format') + )); + } +} diff --git a/application/forms/IcingaImportObjectForm.php b/application/forms/IcingaImportObjectForm.php new file mode 100644 index 0000000..3942f74 --- /dev/null +++ b/application/forms/IcingaImportObjectForm.php @@ -0,0 +1,54 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Form\QuickForm; + +class IcingaImportObjectForm extends QuickForm +{ + /** @var IcingaObject */ + protected $object; + + public function setup() + { + $this->addNote($this->translate( + "Importing an object means that its type will change from" + . ' "external" to "object". That way it will make part of the' + . ' next deployment. So in case you imported this object from' + . ' your Icinga node make sure to remove it from your local' + . ' configuration before issueing the next deployment. In case' + . ' of a conflict nothing bad will happen, just your config' + . " won't deploy." + )); + + $this->submitLabel = sprintf( + $this->translate('Import external "%s"'), + $this->object->object_name + ); + } + + public function onSuccess() + { + $object = $this->object; + if ($object->set('object_type', 'object')->store()) { + $this->redirectOnSuccess(sprintf( + $this->translate('%s "%s" has been imported"'), + $object->getShortTableName(), + $object->getObjectName() + )); + } else { + $this->addError(sprintf( + $this->translate('Failed to import %s "%s"'), + $object->getShortTableName(), + $object->getObjectName() + )); + } + } + + public function setObject(IcingaObject $object) + { + $this->object = $object; + return $this; + } +} diff --git a/application/forms/IcingaMultiEditForm.php b/application/forms/IcingaMultiEditForm.php new file mode 100644 index 0000000..4149a70 --- /dev/null +++ b/application/forms/IcingaMultiEditForm.php @@ -0,0 +1,324 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Hook\IcingaObjectFormHook; +use Icinga\Module\Director\Web\Form\IcingaObjectFieldLoader; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\Form\QuickForm; +use Zend_Form_Element as ZfElement; + +class IcingaMultiEditForm extends DirectorObjectForm +{ + /** @var DbObject[] */ + private $objects; + + private $elementGroupMap; + + /** @var QuickForm */ + private $relatedForm; + + private $propertiesToPick; + + public function setObjects($objects) + { + $this->objects = $objects; + $this->object = current($this->objects); + $this->db = $this->object()->getConnection(); + return $this; + } + + public function isMultiObjectForm() + { + return true; + } + + public function pickElementsFrom(QuickForm $form, $properties) + { + $this->relatedForm = $form; + $this->propertiesToPick = $properties; + return $this; + } + + public function setup() + { + $object = $this->object; + + $loader = new IcingaObjectFieldLoader($object); + $loader->prepareElements($this); + $loader->addFieldsToForm($this); + + if ($form = $this->relatedForm) { + if ($form instanceof DirectorObjectForm) { + $form->setDb($object->getConnection()) + ->setObject($object); + } + + $form->prepareElements(); + } else { + $this->propertiesToPick = array(); + } + + foreach ($this->propertiesToPick as $property) { + if ($el = $form->getElement($property)) { + $this->makeVariants($el); + } + } + + /** @var \Zend_Form_Element $el */ + foreach ($this->getElements() as $el) { + $name = $el->getName(); + if (substr($name, 0, 4) === 'var_') { + $this->makeVariants($el); + } + } + + $this->setButtons(); + } + + public function onSuccess() + { + foreach ($this->getValues() as $key => $value) { + $this->setSubmittedMultiValue($key, $value); + } + + $modified = $this->storeModifiedObjects(); + if ($modified === 0) { + $msg = $this->translate('No object has been modified'); + } elseif ($modified === 1) { + $msg = $this->translate('One object has been modified'); + } else { + $msg = sprintf( + $this->translate('%d objects have been modified'), + $modified + ); + } + + $this->redirectOnSuccess($msg); + } + + /** + * No default objects behaviour + */ + protected function onRequest() + { + IcingaObjectFormHook::callOnSetup($this); + if ($this->hasBeenSent()) { + $this->handlePost(); + } + } + + protected function handlePost() + { + $this->callOnRequestCallables(); + if ($this->shouldBeDeleted()) { + $this->deleteObjects(); + } + } + + protected function setSubmittedMultiValue($key, $value) + { + $parts = preg_split('/_/', $key); + $objectsSum = array_pop($parts); + $valueSum = array_pop($parts); + $property = implode('_', $parts); + + if ($value === '') { + $value = null; + } + + foreach ($this->getVariants($property) as $json => $objects) { + if ($valueSum !== sha1($json)) { + continue; + } + + if ($objectsSum !== sha1(json_encode($objects))) { + continue; + } + + if (substr($property, 0, 4) === 'var_') { + $property = 'vars.' . substr($property, 4); + } + + foreach ($this->getObjects($objects) as $object) { + $object->$property = $value; + } + } + } + + protected function storeModifiedObjects() + { + $modified = 0; + $store = $this->getDbObjectStore(); + foreach ($this->objects as $object) { + if ($object->hasBeenModified()) { + $modified++; + $store->store($object); + } + } + + return $modified; + } + + protected function getDisplayGroupForElement(ZfElement $element) + { + if ($this->elementGroupMap === null) { + $this->resolveDisplayGroups(); + } + + $name = $element->getName(); + if (array_key_exists($name, $this->elementGroupMap)) { + $groupName = $this->elementGroupMap[$name]; + + if ($group = $this->getDisplayGroup($groupName)) { + return $group; + } elseif ($this->relatedForm) { + return $this->stealDisplayGroup($groupName, $this->relatedForm); + } + } + + return null; + } + + protected function stealDisplayGroup($name, QuickForm $form) + { + if ($group = $form->getDisplayGroup($name)) { + $group = clone($group); + $group->setElements(array()); + $this->_displayGroups[$name] = $group; + $this->_order[$name] = $group->getOrder(); + $this->_orderUpdated = true; + + return $group; + } + + return null; + } + + protected function resolveDisplayGroups() + { + $this->elementGroupMap = array(); + if ($form = $this->relatedForm) { + $this->extractFormDisplayGroups($form); + } + + $this->extractFormDisplayGroups($this); + } + + protected function extractFormDisplayGroups(QuickForm $form) + { + /** @var \Zend_Form_DisplayGroup $group */ + foreach ($form->getDisplayGroups() as $group) { + $groupName = $group->getName(); + foreach ($group->getElements() as $name => $e) { + $this->elementGroupMap[$name] = $groupName; + } + } + } + + protected function makeVariants(ZfElement $element) + { + $key = $element->getName(); + $this->removeElement($key); + $label = $element->getLabel(); + $group = $this->getDisplayGroupForElement($element); + $description = $element->getDescription(); + + foreach ($this->getVariants($key) as $json => $objects) { + $value = json_decode($json); + $checksum = sha1($json) . '_' . sha1(json_encode($objects)); + + $v = clone($element); + $v->setName($key . '_' . $checksum); + $v->setDescription($description . ' ' . $this->descriptionForObjects($objects)); + $v->setLabel($label . $this->labelCount($objects)); + $v->setValue($value); + if ($group) { + $group->addElement($v); + } + $this->addElement($v); + } + } + + protected function getVariants($key) + { + $variants = array(); + if (substr($key, 0, 4) === 'var_') { + $key = 'vars.' . substr($key, 4); + } + + foreach ($this->objects as $name => $object) { + $value = json_encode($object->$key); + if (! array_key_exists($value, $variants)) { + $variants[$value] = array(); + } + + $variants[$value][] = $name; + } + + foreach ($variants as & $objects) { + natsort($objects); + } + + return $variants; + } + + protected function descriptionForObjects($list) + { + return sprintf( + $this->translate('Changing this value affects %d object(s): %s'), + count($list), + implode(', ', $list) + ); + } + + protected function labelCount($list) + { + return ' (' . count($list) . ')'; + } + + protected function db() + { + if ($this->db === null) { + $this->db = $this->object()->getConnection(); + } + + return $this->db; + } + + public function getObjects($names = null) + { + if ($names === null) { + return $this->objects; + } + + $res = array(); + + foreach ($names as $name) { + $res[$name] = $this->objects[$name]; + } + + return $res; + } + + protected function deleteObjects() + { + $msg = sprintf( + '%d objects of type "%s" have been removed', + count($this->objects), + $this->translate($this->object->getShortTableName()) + ); + + $store = $this->getDbObjectStore(); + foreach ($this->objects as $object) { + $store->delete($object); + } + + if ($this->listUrl) { + $this->setSuccessUrl($this->listUrl); + } + + $this->redirectOnSuccess($msg); + } +} diff --git a/application/forms/IcingaNotificationForm.php b/application/forms/IcingaNotificationForm.php new file mode 100644 index 0000000..0fca6b8 --- /dev/null +++ b/application/forms/IcingaNotificationForm.php @@ -0,0 +1,298 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaNotificationForm extends DirectorObjectForm +{ + public function setup() + { + $this->addObjectTypeElement(); + if (! $this->hasObjectType()) { + $this->groupMainProperties(); + return; + } + + if ($this->isTemplate()) { + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Notification Template'), + 'required' => true, + 'description' => $this->translate('Name for the Icinga notification template you are going to create') + )); + } else { + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Notification'), + 'required' => true, + 'description' => $this->translate('Name for the Icinga notification you are going to create') + )); + + $this->eventuallyAddNameRestriction( + 'director/notification/apply/filter-by-name' + ); + } + + $this->addDisabledElement() + ->addImportsElement() + ->addUsersElement() + ->addUsergroupsElement() + ->addIntervalElement() + ->addPeriodElement() + ->addTimesElements() + ->addAssignmentElements() + ->addDisabledElement() + ->addCommandElements() + ->addEventFilterElements() + ->addZoneElements() + ->groupMainProperties() + ->setButtons(); + } + + protected function addZoneElements() + { + if (! $this->isTemplate()) { + return $this; + } + + $this->addZoneElement(); + $this->addDisplayGroup(array('zone_id'), 'clustering', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_CLUSTERING, + 'legend' => $this->translate('Zone settings') + )); + + return $this; + } + + /** + * @return self + */ + protected function addAssignmentElements() + { + if (!$this->object || !$this->object->isApplyRule()) { + return $this; + } + + $this->addElement('select', 'apply_to', array( + 'label' => $this->translate('Apply to'), + 'description' => $this->translate( + 'Whether this notification should affect hosts or services' + ), + 'required' => true, + 'class' => 'autosubmit', + 'multiOptions' => $this->optionalEnum( + array( + 'host' => $this->translate('Hosts'), + 'service' => $this->translate('Services'), + ) + ) + )); + + $applyTo = $this->getSentOrObjectValue('apply_to'); + + if (! $applyTo) { + return $this; + } + + $suggestionContext = ucfirst($applyTo) . 'FilterColumns'; + $this->addAssignFilter([ + 'required' => true, + 'suggestionContext' => $suggestionContext, + 'description' => $this->translate( + 'This allows you to configure an assignment filter. Please feel' + . ' free to combine as many nested operators as you want. The' + . ' "contains" operator is valid for arrays only. Please use' + . ' wildcards and the = (equals) operator when searching for' + . ' partial string matches, like in *.example.com' + ) + ]); + + return $this; + } + + /** + * @return $this + */ + protected function addUsersElement() + { + $users = $this->enumUsers(); + if (empty($users)) { + return $this; + } + + $this->addElement( + 'extensibleSet', + 'users', + array( + 'label' => $this->translate('Users'), + 'description' => $this->translate( + 'Users that should be notified by this notifications' + ), + 'multiOptions' => $this->optionalEnum($users) + ) + ); + + return $this; + } + + /** + * @return $this + */ + protected function addUsergroupsElement() + { + $groups = $this->enumUsergroups(); + if (empty($groups)) { + return $this; + } + + $this->addElement( + 'extensibleSet', + 'user_groups', + array( + 'label' => $this->translate('User groups'), + 'description' => $this->translate( + 'User groups that should be notified by this notifications' + ), + 'multiOptions' => $this->optionalEnum($groups) + ) + ); + + return $this; + } + + /** + * @return self + */ + protected function addIntervalElement() + { + $this->addElement( + 'text', + 'notification_interval', + array( + 'label' => $this->translate('Notification interval'), + 'description' => $this->translate( + 'The notification interval (in seconds). This interval is' + . ' used for active notifications. Defaults to 30 minutes.' + . ' If set to 0, re-notifications are disabled.' + ) + ) + ); + + return $this; + } + + /** + * @return self + */ + protected function addTimesElements() + { + $this->addElement( + 'text', + 'times_begin', + array( + 'label' => $this->translate('First notification delay'), + 'description' => $this->translate( + 'Delay unless the first notification should be sent' + ) . '. ' . $this->getTimeValueInfo() + ) + ); + + $this->addElement( + 'text', + 'times_end', + array( + 'label' => $this->translate('Last notification'), + 'description' => $this->translate( + 'When the last notification should be sent' + ) . '. ' . $this->getTimeValueInfo() + ) + ); + + return $this; + } + + protected function getTimeValueInfo() + { + return $this->translate( + 'Unit is seconds unless a suffix is given. Supported suffixes include' + . ' ms (milliseconds), s (seconds), m (minutes), h (hours) and d (days).' + ); + } + + /** + * @return self + */ + protected function addPeriodElement() + { + $periods = $this->db->enumTimeperiods(); + if (empty($periods)) { + return $this; + } + + $this->addElement( + 'select', + 'period_id', + array( + 'label' => $this->translate('Time period'), + 'description' => $this->translate( + 'The name of a time period which determines when this' + . ' notification should be triggered. Not set by default.' + ), + 'multiOptions' => $this->optionalEnum($periods), + ) + ); + + return $this; + } + + /** + * @return self + */ + protected function addCommandElements() + { + if (! $this->isTemplate()) { + return $this; + } + + $this->addElement('select', 'command_id', array( + 'label' => $this->translate('Notification command'), + 'description' => $this->translate('Check command definition'), + 'multiOptions' => $this->optionalEnum($this->db->enumNotificationCommands()), + 'class' => 'autosubmit', + )); + + return $this; + } + + protected function enumUsers() + { + $db = $this->db->getDbAdapter(); + $select = $db->select()->from( + 'icinga_user', + array( + 'name' => 'object_name', + 'display' => 'COALESCE(display_name, object_name)' + ) + )->where('object_type = ?', 'object')->order('display'); + + return $db->fetchPairs($select); + } + + protected function enumUsergroups() + { + $db = $this->db->getDbAdapter(); + $select = $db->select()->from( + 'icinga_usergroup', + array( + 'name' => 'object_name', + 'display' => 'COALESCE(display_name, object_name)' + ) + )->where('object_type = ?', 'object')->order('display'); + + return $db->fetchPairs($select); + } +} diff --git a/application/forms/IcingaObjectFieldForm.php b/application/forms/IcingaObjectFieldForm.php new file mode 100644 index 0000000..537c95e --- /dev/null +++ b/application/forms/IcingaObjectFieldForm.php @@ -0,0 +1,219 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\Form\IcingaObjectFieldLoader; + +class IcingaObjectFieldForm extends DirectorObjectForm +{ + /** @var IcingaObject Please note that $object would conflict with logic in parent class */ + protected $icingaObject; + + public function setIcingaObject($object) + { + $this->icingaObject = $object; + $this->className = get_class($object) . 'Field'; + return $this; + } + + public function setup() + { + $object = $this->icingaObject; + $type = $object->getShortTableName(); + $this->addHidden($type . '_id', $object->get('id')); + + $this->addHtmlHint( + 'Custom data fields allow you to easily fill custom variables with' + . " meaningful data. It's perfectly legal to override inherited fields." + . ' You may for example want to allow "network devices" specifying any' + . ' string for vars.snmp_community, but restrict "customer routers" to' + . ' a specific set, shown as a dropdown.' + ); + + // TODO: remove assigned ones! + $existingFields = $this->db->enumDatafields(); + $blacklistedVars = array(); + $suggestedFields = array(); + + foreach ($existingFields as $id => $field) { + if (preg_match('/ \(([^\)]+)\)$/', $field, $m)) { + $blacklistedVars['$' . $m[1] . '$'] = $id; + } + } + + // TODO: think about imported existing vars without fields + // TODO: extract vars from command line (-> dummy) + // TODO: do not suggest chosen ones + $argumentVars = array(); + $argumentVarDescriptions = array(); + if ($object instanceof IcingaCommand) { + $command = $object; + } elseif ($object->hasProperty('check_command_id')) { + $command = $object->getResolvedRelated('check_command'); + } else { + $command = null; + } + + if ($command) { + foreach ($command->arguments() as $arg) { + if ($arg->argument_format === 'string') { + $val = $arg->argument_value; + // TODO: create var::extractMacros or so + + if (preg_match_all('/(\$[a-z0-9_]+\$)/i', $val, $m, PREG_PATTERN_ORDER)) { + foreach ($m[1] as $val) { + if (array_key_exists($val, $blacklistedVars)) { + $id = $blacklistedVars[$val]; + + // Hint: if not set it might already have been + // removed in this loop + if (array_key_exists($id, $existingFields)) { + $suggestedFields[$id] = $existingFields[$id]; + unset($existingFields[$id]); + } + } else { + $argumentVars[$val] = $val; + $argumentVarDescriptions[$val] = $arg->description; + } + } + } + } + } + } + + // Prepare combined fields array + $fields = array(); + if (! empty($suggestedFields)) { + asort($existingFields); + $fields[$this->translate('Suggested fields')] = $suggestedFields; + } + + if (! empty($argumentVars)) { + ksort($argumentVars); + $fields[$this->translate('Argument macros')] = $argumentVars; + } + + if (! empty($existingFields)) { + $fields[$this->translate('Other available fields')] = $existingFields; + } + + $this->addElement('select', 'datafield_id', array( + 'label' => 'Field', + 'required' => true, + 'description' => 'Field to assign', + 'class' => 'autosubmit', + 'multiOptions' => $this->optionalEnum($fields) + )); + + if (empty($fields)) { + // TODO: show message depending on permissions + $msg = $this->translate( + 'There are no data fields available. Please ask an administrator to create such' + ); + + $this->getElement('datafield_id')->addError($msg); + } + + if (($id = $this->getSentValue('datafield_id')) && ! ctype_digit($id)) { + $this->addElement('text', 'caption', array( + 'label' => $this->translate('Caption'), + 'required' => true, + 'ignore' => true, + 'value' => trim($id, '$'), + 'description' => $this->translate('The caption which should be displayed') + )); + + $this->addElement('textarea', 'description', array( + 'label' => $this->translate('Description'), + 'description' => $this->translate('A description about the field'), + 'ignore' => true, + 'value' => array_key_exists($id, $argumentVarDescriptions) ? $argumentVarDescriptions[$id] : null, + 'rows' => '3', + )); + } + + $this->addElement('select', 'is_required', array( + 'label' => $this->translate('Mandatory'), + 'description' => $this->translate('Whether this field should be mandatory'), + 'required' => true, + 'multiOptions' => array( + 'n' => $this->translate('Optional'), + 'y' => $this->translate('Mandatory'), + ) + )); + + $filterFields = array(); + $prefix = null; + if ($object instanceof IcingaHost) { + $prefix = 'host.vars.'; + } elseif ($object instanceof IcingaService) { + $prefix = 'service.vars.'; + } + + if ($prefix) { + $loader = new IcingaObjectFieldLoader($object); + $fields = $loader->getFields(); + + foreach ($fields as $varName => $field) { + $filterFields[$prefix . $field->varname] = $field->caption; + } + + $this->addFilterElement('var_filter', array( + 'description' => $this->translate( + 'You might want to show this field only when certain conditions are met.' + . ' Otherwise it will not be available and values eventually set before' + . ' will be cleared once stored' + ), + 'columns' => $filterFields, + )); + + $this->addDisplayGroup(array($this->getElement('var_filter')), 'field_filter', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => 30, + 'legend' => $this->translate('Show based on filter') + )); + } + + $this->setButtons(); + } + + protected function onRequest() + { + parent::onRequest(); + if ($this->getSentValue('delete') === $this->translate('Delete')) { + $this->object()->delete(); + $this->setSuccessUrl($this->getSuccessUrl()->without('field_id')); + $this->redirectOnSuccess($this->translate('Field has been removed')); + } + } + + public function onSuccess() + { + $fieldId = $this->getValue('datafield_id'); + + if (! ctype_digit($fieldId)) { + $field = DirectorDatafield::create(array( + 'varname' => trim($fieldId, '$'), + 'caption' => $this->getValue('caption'), + 'description' => $this->getValue('description'), + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', + )); + $field->store($this->getDb()); + $this->setElementValue('datafield_id', $field->get('id')); + $this->object()->set('datafield_id', $field->get('id')); + } + + $this->object()->set('var_filter', $this->getValue('var_filter')); + return parent::onSuccess(); + } +} diff --git a/application/forms/IcingaScheduledDowntimeForm.php b/application/forms/IcingaScheduledDowntimeForm.php new file mode 100644 index 0000000..b126d59 --- /dev/null +++ b/application/forms/IcingaScheduledDowntimeForm.php @@ -0,0 +1,133 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaScheduledDowntimeForm extends DirectorObjectForm +{ + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + if ($this->isTemplate()) { + $this->addElement('text', 'object_name', [ + 'label' => $this->translate('Template name'), + 'required' => true, + ]); + } else { + $this->addElement('text', 'object_name', [ + 'label' => $this->translate('Downtime name'), + 'required' => true, + ]); + } + + if ($this->object()->isApplyRule()) { + $this->eventuallyAddNameRestriction('director/scheduled-downtime/apply/filter-by-name'); + } + $this->addImportsElement(); + $this->addElement('text', 'author', [ + 'label' => $this->translate('Author'), + 'description' => $this->translate( + 'This name will show up as the author for ever related downtime' + . ' comment' + ), + 'required' => ! $this->isTemplate() + ]); + $this->addElement('textarea', 'comment', [ + 'label' => $this->translate('Comment'), + 'description' => $this->translate( + 'Every related downtime will show this comment' + ), + 'required' => ! $this->isTemplate(), + 'rows' => 4, + ]); + $this->addBoolean('fixed', [ + 'label' => $this->translate('Fixed'), + 'description' => $this->translate( + 'Whether this downtime is fixed or flexible. If unsure please' + . ' check the related documentation:' + . ' https://icinga.com/docs/icinga2/latest/doc/08-advanced-topics/#downtimes' + ), + 'required' => ! $this->isTemplate(), + ]); + $this->addElement('text', 'duration', [ + 'label' => $this->translate('Duration'), + 'description' => $this->translate( + 'How long the downtime lasts. Only has an effect for flexible' + . ' (non-fixed) downtimes. Time in seconds, supported suffixes' + . ' include ms (milliseconds), s (seconds), m (minutes),' + . ' h (hours) and d (days). To express "90 minutes" you might' + . ' want to write 1h 30m' + ) + ]); + $this->addDisabledElement(); + $this->addAssignmentElements(); + $this->setButtons(); + } + + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addAssignmentElements() + { + if ($this->isTemplate()) { + return $this; + } + + $this->addElement('select', 'apply_to', [ + 'label' => $this->translate('Apply to'), + 'description' => $this->translate( + 'Whether this dependency should affect hosts or services' + ), + 'required' => true, + 'class' => 'autosubmit', + 'multiOptions' => $this->optionalEnum([ + 'host' => $this->translate('Hosts'), + 'service' => $this->translate('Services'), + ]) + ]); + + $applyTo = $this->getSentOrObjectValue('apply_to'); + + if (! $applyTo) { + return $this; + } + + if ($applyTo === 'host') { + $this->addBoolean('with_services', [ + 'label' => $this->translate('With Services'), + 'description' => $this->translate( + 'Whether Downtimes should also explicitly be scheduled for' + . ' all Services belonging to affected Hosts' + ) + ]); + } + + $suggestionContext = ucfirst($applyTo) . 'FilterColumns'; + $this->addAssignFilter([ + 'suggestionContext' => $suggestionContext, + 'required' => true, + 'description' => $this->translate( + 'This allows you to configure an assignment filter. Please feel' + . ' free to combine as many nested operators as you want. The' + . ' "contains" operator is valid for arrays only. Please use' + . ' wildcards and the = (equals) operator when searching for' + . ' partial string matches, like in *.example.com' + ) + ]); + + return $this; + } + + protected function setObjectSuccessUrl() + { + $this->setSuccessUrl( + 'director/scheduled-downtime', + $this->object()->getUrlParams() + ); + } +} diff --git a/application/forms/IcingaScheduledDowntimeRangeForm.php b/application/forms/IcingaScheduledDowntimeRangeForm.php new file mode 100644 index 0000000..b5f95d0 --- /dev/null +++ b/application/forms/IcingaScheduledDowntimeRangeForm.php @@ -0,0 +1,110 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaScheduledDowntime; +use Icinga\Module\Director\Objects\IcingaScheduledDowntimeRange; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaScheduledDowntimeRangeForm extends DirectorObjectForm +{ + /** @var IcingaScheduledDowntime */ + private $downtime; + + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->addHidden('scheduled_downtime_id', $this->downtime->get('id')); + $this->addElement('text', 'range_key', [ + 'label' => $this->translate('Day(s)'), + 'description' => $this->translate( + 'Might be monday, tuesday or 2016-01-28 - have a look at the documentation for more examples' + ), + ]); + + $this->addElement('text', 'range_value', [ + 'label' => $this->translate('Timeperiods'), + 'description' => $this->translate( + 'One or more time periods, e.g. 00:00-24:00 or 00:00-09:00,17:00-24:00' + ), + ]); + + $this->setButtons(); + } + + public function setScheduledDowntime(IcingaScheduledDowntime $downtime) + { + $this->downtime = $downtime; + $this->setDb($downtime->getConnection()); + return $this; + } + + /** + * @param IcingaScheduledDowntimeRange $object + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function deleteObject($object) + { + $key = $object->get('range_key'); + $downtime = $this->downtime; + $downtime->ranges()->remove($key); + $downtime->store(); + $msg = sprintf( + $this->translate('Time range "%s" has been removed from %s'), + $key, + $downtime->getObjectName() + ); + + $url = $this->getSuccessUrl()->without( + ['range', 'range_type'] + ); + + $this->setSuccessUrl($url); + $this->redirectOnSuccess($msg); + } + + /** + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function onSuccess() + { + $object = $this->object(); + if ($object->hasBeenModified()) { + $this->downtime->ranges()->setRange( + $this->getValue('range_key'), + $this->getValue('range_value') + ); + } + + if ($this->downtime->hasBeenModified()) { + if (! $object->hasBeenLoadedFromDb()) { + $this->setHttpResponseCode(201); + } + + $msg = sprintf( + $object->hasBeenLoadedFromDb() + ? $this->translate('The %s has successfully been stored') + : $this->translate('A new %s has successfully been created'), + $this->translate($this->getObjectShortClassName()) + ); + + $this->downtime->store($this->db); + } else { + if ($this->isApiRequest()) { + $this->setHttpResponseCode(304); + } + $msg = $this->translate('No action taken, object has not been modified'); + } + if ($object instanceof IcingaObject) { + $this->setSuccessUrl( + 'director/' . strtolower($this->getObjectShortClassName()), + $object->getUrlParams() + ); + } + + $this->redirectOnSuccess($msg); + } +} diff --git a/application/forms/IcingaServiceDictionaryMemberForm.php b/application/forms/IcingaServiceDictionaryMemberForm.php new file mode 100644 index 0000000..90b8f94 --- /dev/null +++ b/application/forms/IcingaServiceDictionaryMemberForm.php @@ -0,0 +1,54 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Objects\IcingaService; + +class IcingaServiceDictionaryMemberForm extends DirectorObjectForm +{ + /** @var IcingaService */ + protected $object; + + private $succeeded; + + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->addHidden('object_type', 'object'); + $this->addElement('text', 'object_name', [ + 'label' => $this->translate('Name'), + 'required' => !$this->object()->isApplyRule(), + 'description' => $this->translate( + 'Name for the instance you are going to create' + ) + ]); + $this->groupMainProperties()->setButtons(); + } + + protected function isNew() + { + return $this->object === null; + } + + protected function deleteObject($object) + { + } + + protected function getObjectClassname() + { + return IcingaService::class; + } + + public function succeeded() + { + return $this->succeeded; + } + + public function onSuccess() + { + $this->succeeded = true; + } +} diff --git a/application/forms/IcingaServiceForm.php b/application/forms/IcingaServiceForm.php new file mode 100644 index 0000000..f22f9e6 --- /dev/null +++ b/application/forms/IcingaServiceForm.php @@ -0,0 +1,806 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use gipfl\Web\Widget\Hint; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Data\PropertiesFilter\ArrayCustomVariablesFilter; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Icinga\Module\Director\Web\Table\ObjectsTableHost; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use RuntimeException; + +class IcingaServiceForm extends DirectorObjectForm +{ + /** @var IcingaHost */ + private $host; + + /** @var IcingaServiceSet */ + private $set; + + private $apply; + + /** @var IcingaService */ + protected $object; + + /** @var IcingaService */ + private $applyGenerated; + + private $inheritedFrom; + + /** @var bool|null */ + private $blacklisted; + + public function setApplyGenerated(IcingaService $applyGenerated) + { + $this->applyGenerated = $applyGenerated; + + return $this; + } + + public function setInheritedFrom($hostname) + { + $this->inheritedFrom = $hostname; + + return $this; + } + + /** + * @throws IcingaException + * @throws ProgrammingError + * @throws \Zend_Form_Exception + */ + public function setup() + { + if (!$this->isNew() || $this->providesOverrides()) { + $this->tryToFetchHost(); + } + + if ($this->providesOverrides()) { + return; + } + + if ($this->host && $this->set) { + // Probably never reached, as providesOverrides includes this + $this->setupOnHostForSet(); + + return; + } + + if ($this->set !== null) { + $this->setupSetRelatedElements(); + } elseif ($this->host === null) { + $this->setupServiceElements(); + } else { + $this->setupHostRelatedElements(); + } + } + + protected function tryToFetchHost() + { + try { + if ($this->host === null) { + $this->host = $this->object->getResolvedRelated('host'); + } + } catch (NestingError $nestingError) { + // ignore for the form to load + } + } + + public function providesOverrides() + { + return $this->applyGenerated + || $this->inheritedFrom + || ($this->host && $this->set) + || ($this->object && $this->object->usesVarOverrides()); + } + + /** + * @throws IcingaException + * @throws ProgrammingError + * @throws \Zend_Form_Exception + */ + protected function addFields() + { + if ($this->providesOverrides() && $this->hasBeenBlacklisted()) { + $this->onAddedFields(); + + return; + } else { + parent::addFields(); + } + } + + /** + * @throws IcingaException + * @throws ProgrammingError + * @throws \Zend_Form_Exception + */ + protected function onAddedFields() + { + if (! $this->providesOverrides()) { + return; + } + $hasDeleteButton = false; + $isBranch = $this->branch && $this->branch->isBranch(); + + if ($this->hasBeenBlacklisted()) { + $this->addHtml( + Hint::warning($this->translate('This Service has been deactivated on this host')), + ['name' => 'HINT_blacklisted'] + ); + $group = null; + if (! $isBranch) { + $this->addDeleteButton($this->translate('Reactivate')); + $hasDeleteButton = true; + } + $this->setSubmitLabel(false); + } else { + $this->addOverrideHint(); + $group = $this->getDisplayGroup('custom_fields'); + if (! $group) { + foreach ($this->getDisplayGroups() as $groupName => $eventualGroup) { + if (preg_match('/^custom_fields:/', $groupName)) { + $group = $eventualGroup; + break; + } + } + } + if ($group) { + $elements = $group->getElements(); + $group->setElements([$this->getElement('inheritance_hint')]); + $group->addElements($elements); + $this->setSubmitLabel($this->translate('Override vars')); + } else { + $this->addElementsToGroup( + ['inheritance_hint'], + 'custom_fields', + 20, + $this->translate('Hints regarding this service') + ); + + $this->setSubmitLabel(false); + } + + if (! $isBranch) { + $this->addDeleteButton($this->translate('Deactivate')); + $hasDeleteButton = true; + } + } + + if (! $this->hasSubmitButton() && $hasDeleteButton) { + $this->addDisplayGroup([$this->deleteButtonName], 'buttons', [ + 'decorators' => [ + 'FormElements', + ['HtmlTag', ['tag' => 'dl']], + 'DtDdWrapper', + ], + 'order' => self::GROUP_ORDER_BUTTONS, + ]); + } + } + + /** + * @return IcingaHost|null + */ + public function getHost() + { + return $this->host; + } + + /** + * @param IcingaService $service + * @return IcingaService + * @throws \Icinga\Exception\NotFoundError + */ + protected function getFirstParent(IcingaService $service) + { + /** @var IcingaService[] $objects */ + $objects = $service->imports()->getObjects(); + if (empty($objects)) { + throw new RuntimeException('Something went wrong, got no parent'); + } + reset($objects); + + return current($objects); + } + + /** + * @return bool + * @throws \Icinga\Exception\NotFoundError + */ + protected function hasBeenBlacklisted() + { + if (! $this->providesOverrides() || $this->object === null) { + return false; + } + + if ($this->blacklisted === null) { + $host = $this->host; + // Safety check, branches + $hostId = $host->get('id'); + $service = $this->getServiceToBeBlacklisted(); + $serviceId = $service->get('id'); + if (! $hostId || ! $serviceId) { + return false; + } + $db = $this->db->getDbAdapter(); + if ($this->providesOverrides()) { + $this->blacklisted = 1 === (int)$db->fetchOne( + $db->select()->from('icinga_host_service_blacklist', 'COUNT(*)') + ->where('host_id = ?', $hostId) + ->where('service_id = ?', $serviceId) + ); + } else { + $this->blacklisted = false; + } + } + + return $this->blacklisted; + } + + /** + * @param $object + * @throws IcingaException + * @throws ProgrammingError + * @throws \Zend_Db_Adapter_Exception + */ + protected function deleteObject($object) + { + /** @var IcingaService $object */ + if ($this->providesOverrides()) { + if ($this->hasBeenBlacklisted()) { + $this->removeFromBlacklist(); + } else { + $this->blacklist(); + } + } else { + parent::deleteObject($object); + } + } + + /** + * @throws IcingaException + * @throws \Zend_Db_Adapter_Exception + */ + protected function blacklist() + { + $host = $this->host; + $service = $this->getServiceToBeBlacklisted(); + + $db = $this->db->getDbAdapter(); + $host->unsetOverriddenServiceVars($this->object->getObjectName())->store(); + + if ($db->insert('icinga_host_service_blacklist', [ + 'host_id' => $host->get('id'), + 'service_id' => $service->get('id') + ])) { + $msg = sprintf( + $this->translate('%s has been deactivated on %s'), + $service->getObjectName(), + $host->getObjectName() + ); + $this->redirectOnSuccess($msg); + } + } + + /** + * @return IcingaService + * @throws \Icinga\Exception\NotFoundError + */ + protected function getServiceToBeBlacklisted() + { + if ($this->set) { + return $this->object; + } else { + return $this->getFirstParent($this->object); + } + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + protected function removeFromBlacklist() + { + $host = $this->host; + $service = $this->getServiceToBeBlacklisted(); + + $db = $this->db->getDbAdapter(); + $where = implode(' AND ', [ + $db->quoteInto('host_id = ?', $host->get('id')), + $db->quoteInto('service_id = ?', $service->get('id')), + ]); + if ($db->delete('icinga_host_service_blacklist', $where)) { + $msg = sprintf( + $this->translate('%s is no longer deactivated on %s'), + $service->getObjectName(), + $host->getObjectName() + ); + $this->redirectOnSuccess($msg); + } + } + + /** + * @param IcingaService $service + * @return $this + */ + public function createApplyRuleFor(IcingaService $service) + { + $this->apply = $service; + $object = $this->object(); + $object->set('imports', $service->getObjectName()); + $object->set('object_type', 'apply'); + $object->set('object_name', $service->getObjectName()); + + return $this; + } + + /** + * @throws \Zend_Form_Exception + */ + protected function setupServiceElements() + { + if ($this->object) { + $objectType = $this->object->get('object_type'); + } elseif ($this->preferredObjectType) { + $objectType = $this->preferredObjectType; + } else { + $objectType = 'template'; + } + $this->addHidden('object_type', $objectType); + $forceCommandElements = $this->hasPermission('director/admin'); + + $this->addNameElement() + ->addHostObjectElement() + ->addImportsElement() + ->addChoices('service') + ->addGroupsElement() + ->addDisabledElement() + ->addApplyForElement() + ->groupMainProperties() + ->addAssignmentElements() + ->addCheckCommandElements($forceCommandElements) + ->addCheckExecutionElements() + ->addExtraInfoElements() + ->addAgentAndZoneElements() + ->setButtons(); + } + + /** + * @throws IcingaException + * @throws ProgrammingError + */ + protected function addOverrideHint() + { + if ($this->object && $this->object->usesVarOverrides()) { + $hint = $this->translate( + 'This service has been generated in an automated way, but still' + . ' allows you to override the following properties in a safe way.' + ); + } elseif ($apply = $this->applyGenerated) { + $hint = Html::sprintf( + $this->translate( + 'This service has been generated using the %s apply rule, assigned where %s' + ), + Link::create( + $apply->getObjectName(), + 'director/service', + ['id' => $apply->get('id')], + ['data-base-target' => '_next'] + ), + (string) Filter::fromQueryString($apply->assign_filter) + ); + } elseif ($this->host && $this->set) { + $hint = Html::sprintf( + $this->translate( + 'This service belongs to the %s Service Set. Still, you might want' + . ' to override the following properties for this host only.' + ), + Link::create( + $this->set->getObjectName(), + 'director/serviceset', + ['id' => $this->set->get('id')], + ['data-base-target' => '_next'] + ) + ); + } elseif ($this->inheritedFrom) { + $msg = $this->translate( + 'This service has been inherited from %s. Still, you might want' + . ' to change the following properties for this host only.' + ); + + $name = $this->inheritedFrom; + $link = Link::create( + $name, + 'director/service', + [ + 'host' => $name, + 'name' => $this->object->getObjectName(), + ], + ['data-base-target' => '_next'] + ); + + $hint = Html::sprintf($msg, $link); + } else { + throw new ProgrammingError('Got no override hint for your situation'); + } + + $this->setSubmitLabel($this->translate('Override vars')); + + $this->addHtmlHint($hint, ['name' => 'inheritance_hint']); + } + + protected function setupOnHostForSet() + { + $msg = $this->translate( + 'This service belongs to the service set "%s". Still, you might want' + . ' to change the following properties for this host only.' + ); + + $name = $this->set->getObjectName(); + $link = Link::create( + $name, + 'director/serviceset', + ['name' => $name], + ['data-base-target' => '_next'] + ); + + $this->addHtmlHint( + Html::sprintf($msg, $link), + ['name' => 'inheritance_hint'] + ); + + $this->addElementsToGroup( + ['inheritance_hint'], + 'custom_fields', + 50, + $this->translate('Custom properties') + ); + + $this->setSubmitLabel($this->translate('Override vars')); + } + + protected function addAssignmentElements() + { + $this->addAssignFilter([ + 'suggestionContext' => 'HostFilterColumns', + 'required' => true, + 'description' => $this->translate( + 'This allows you to configure an assignment filter. Please feel' + . ' free to combine as many nested operators as you want. The' + . ' "contains" operator is valid for arrays only. Please use' + . ' wildcards and the = (equals) operator when searching for' + . ' partial string matches, like in *.example.com' + ) + ]); + + return $this; + } + + /** + * @throws \Zend_Form_Exception + */ + protected function setupHostRelatedElements() + { + $this->addHidden('host', $this->host->getObjectName()); + $this->addHidden('object_type', 'object'); + $this->addImportsElement(); + $imports = $this->getSentOrObjectValue('imports'); + + if ($this->hasBeenSent()) { + $imports = $this->getElement('imports')->setValue($imports)->getValue(); + } + + if ($this->isNew() && empty($imports)) { + $this->groupMainProperties(); + return; + } + + $this->addNameElement() + ->addChoices('service') + ->addDisabledElement() + ->addGroupsElement() + ->groupMainProperties() + ->addCheckCommandElements() + ->addExtraInfoElements() + ->setButtons(); + + $this->setDefaultNameFromTemplate($imports); + } + + /** + * @param IcingaHost $host + * @return $this + */ + public function setHost(IcingaHost $host) + { + $this->host = $host; + return $this; + } + + /** + * @throws \Zend_Form_Exception + */ + protected function setupSetRelatedElements() + { + $this->addHidden('service_set', $this->set->getObjectName()); + $this->addHidden('object_type', 'apply'); + $this->addImportsElement(); + $this->setButtons(); + $imports = $this->getSentOrObjectValue('imports'); + + if ($this->hasBeenSent()) { + $imports = $this->getElement('imports')->setValue($imports)->getValue(); + } + + if ($this->isNew() && empty($imports)) { + $this->groupMainProperties(); + return; + } + + $this->addNameElement() + ->addDisabledElement() + ->addGroupsElement() + ->groupMainProperties(); + + if ($this->hasPermission('director/admin')) { + $this->addCheckCommandElements(true) + ->addCheckExecutionElements(true) + ->addExtraInfoElements(); + } + + $this->setDefaultNameFromTemplate($imports); + } + + public function setServiceSet(IcingaServiceSet $set) + { + $this->set = $set; + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addNameElement() + { + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Name'), + 'required' => !$this->object()->isApplyRule(), + 'description' => $this->translate( + 'Name for the Icinga service you are going to create' + ) + )); + + if ($this->object()->isApplyRule()) { + $this->eventuallyAddNameRestriction('director/service/apply/filter-by-name'); + } + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addHostObjectElement() + { + if ($this->isObject()) { + $this->addElement('select', 'host', [ + 'label' => $this->translate('Host'), + 'required' => true, + 'multiOptions' => $this->optionalEnum($this->enumHostsAndTemplates()), + 'description' => $this->translate( + 'Choose the host this single service should be assigned to' + ) + ]); + } + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addApplyForElement() + { + if ($this->object->isApplyRule()) { + $hostProperties = IcingaHost::enumProperties( + $this->object->getConnection(), + 'host.', + new ArrayCustomVariablesFilter() + ); + + $this->addElement('select', 'apply_for', array( + 'label' => $this->translate('Apply For'), + 'class' => 'assign-property autosubmit', + 'multiOptions' => $this->optionalEnum($hostProperties, $this->translate('None')), + 'description' => $this->translate( + 'Evaluates the apply for rule for ' . + 'all objects with the custom attribute specified. ' . + 'E.g selecting "host.vars.custom_attr" will generate "for (config in ' . + 'host.vars.array_var)" where "config" will be accessible through "$config$". ' . + 'NOTE: only custom variables of type "Array" are eligible.' + ) + )); + } + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addGroupsElement() + { + $groups = $this->enumServicegroups(); + + if (! empty($groups)) { + $this->addElement('extensibleSet', 'groups', array( + 'label' => $this->translate('Groups'), + 'multiOptions' => $this->optionallyAddFromEnum($groups), + 'positional' => false, + 'description' => $this->translate( + 'Service groups that should be directly assigned to this service.' + . ' Servicegroups can be useful for various reasons. They are' + . ' helpful to provided service-type specific view in Icinga Web 2,' + . ' either for custom dashboards or as an instrument to enforce' + . ' restrictions. Service groups can be directly assigned to' + . ' single services or to service templates.' + ) + )); + } + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addAgentAndZoneElements() + { + if (!$this->isTemplate()) { + return $this; + } + + $this->optionalBoolean( + 'use_agent', + $this->translate('Run on agent'), + $this->translate( + 'Whether the check commmand for this service should be executed' + . ' on the Icinga agent' + ) + ); + $this->addZoneElement(); + + $elements = array( + 'use_agent', + 'zone_id', + ); + $this->addDisplayGroup($elements, 'clustering', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_CLUSTERING, + 'legend' => $this->translate('Icinga Agent and zone settings') + )); + + return $this; + } + + protected function enumHostsAndTemplates() + { + if ($this->branch && $this->branch->isBranch()) { + return $this->enumHosts(); + } + + return [ + $this->translate('Templates') => $this->enumHostTemplates(), + $this->translate('Hosts') => $this->enumHosts(), + ]; + } + + protected function enumHostTemplates() + { + $names = array_values($this->db->enumHostTemplates()); + return array_combine($names, $names); + } + + protected function enumHosts() + { + $db = $this->db->getDbAdapter(); + $table = new ObjectsTableHost($this->db); + $table->setAuth($this->getAuth()); + if ($this->branch && $this->branch->isBranch()) { + $table->setBranchUuid($this->branch->getUuid()); + } + $result = []; + foreach ($db->fetchAll($table->getQuery()->reset(\Zend_Db_Select::LIMIT_COUNT)) as $row) { + $result[$row->object_name] = $row->object_name; + } + + return $result; + } + + protected function enumServicegroups() + { + $db = $this->db->getDbAdapter(); + $select = $db->select()->from( + 'icinga_servicegroup', + array( + 'name' => 'object_name', + 'display' => 'COALESCE(display_name, object_name)' + ) + )->where('object_type = ?', 'object')->order('display'); + + return $db->fetchPairs($select); + } + + protected function succeedForOverrides() + { + $vars = array(); + foreach ($this->object->vars() as $key => $var) { + $vars[$key] = $var->getValue(); + } + + $host = $this->host; + $serviceName = $this->object->getObjectName(); + + $this->host->overrideServiceVars($serviceName, (object) $vars); + + if ($host->hasBeenModified()) { + $msg = sprintf( + empty($vars) + ? $this->translate('All overrides have been removed from "%s"') + : $this->translate('The given properties have been stored for "%s"'), + $this->translate($host->getObjectName()) + ); + + $this->getDbObjectStore()->store($host); + } else { + if ($this->isApiRequest()) { + $this->setHttpResponseCode(304); + } + + $msg = $this->translate('No action taken, object has not been modified'); + } + + $this->redirectOnSuccess($msg); + } + + public function onSuccess() + { + if ($this->providesOverrides()) { + $this->succeedForOverrides(); + return; + } + + parent::onSuccess(); + } + + /** + * @param array $imports + */ + protected function setDefaultNameFromTemplate($imports) + { + if ($this->hasBeenSent()) { + $name = $this->getSentOrObjectValue('object_name'); + if ($name === null || !strlen($name)) { + $this->setElementValue('object_name', end($imports)); + $this->object->set('object_name', end($imports)); + } + } + } +} diff --git a/application/forms/IcingaServiceGroupForm.php b/application/forms/IcingaServiceGroupForm.php new file mode 100644 index 0000000..db23cbb --- /dev/null +++ b/application/forms/IcingaServiceGroupForm.php @@ -0,0 +1,40 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaServiceGroupForm extends DirectorObjectForm +{ + public function setup() + { + $this->addHidden('object_type', 'object'); + + $this->addElement('text', 'object_name', [ + 'label' => $this->translate('Servicegroup'), + 'required' => true, + 'description' => $this->translate('Icinga object name for this service group') + ]); + + $this->addGroupDisplayNameElement() + ->addAssignmentElements() + ->setButtons(); + } + + protected function addAssignmentElements() + { + $this->addAssignFilter([ + 'suggestionContext' => 'ServiceFilterColumns', + 'required' => false, + 'description' => $this->translate( + 'This allows you to configure an assignment filter. Please feel' + . ' free to combine as many nested operators as you want. The' + . ' "contains" operator is valid for arrays only. Please use' + . ' wildcards and the = (equals) operator when searching for' + . ' partial string matches, like in *.example.com' + ) + ]); + + return $this; + } +} diff --git a/application/forms/IcingaServiceSetForm.php b/application/forms/IcingaServiceSetForm.php new file mode 100644 index 0000000..21508d5 --- /dev/null +++ b/application/forms/IcingaServiceSetForm.php @@ -0,0 +1,135 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaServiceSetForm extends DirectorObjectForm +{ + protected $host; + + protected $listUrl = 'director/services/sets'; + + public function setup() + { + if ($this->host === null) { + $this->setupTemplate(); + } else { + $this->setupHost(); + } + + $this->setButtons(); + } + + protected function setupTemplate() + { + $this->addElement('text', 'object_name', [ + 'label' => $this->translate('Service set name'), + 'description' => $this->translate( + 'A short name identifying this set of services' + ), + 'required' => true, + ]) + ->eventuallyAddNameRestriction('director/service_set/filter-by-name') + ->addHidden('object_type', 'template') + ->addDescriptionElement() + ->addAssignmentElements(); + } + + protected function setObjectSuccessUrl() + { + if ($this->host) { + $this->setSuccessUrl( + 'director/host/services', + array('name' => $this->host->getObjectName()) + ); + } else { + parent::setObjectSuccessUrl(); + } + } + + protected function setupHost() + { + $object = $this->object(); + if ($this->hasBeenSent()) { + $object->set('object_name', $this->getSentValue('imports')); + $object->set('imports', $object->object_name); + } + + if (! $object->hasBeenLoadedFromDb()) { + $this->addSingleImportsElement(); + } + + if (count($object->get('imports'))) { + $description = $object->getResolvedProperty('description'); + if ($description) { + $this->addHtmlHint($description); + } + } + + $this->addHidden('object_type', 'object'); + $this->addHidden('host', $this->host->getObjectName()); + $this->groupMainProperties(); + } + + public function setHost(IcingaHost $host) + { + $this->host = $host; + return $this; + } + + protected function addSingleImportsElement() + { + $enum = $this->enumAllowedTemplates(); + + $this->addElement('select', 'imports', array( + 'label' => $this->translate('Service set'), + 'description' => $this->translate( + 'The service set that should be assigned to this host' + ), + 'required' => true, + 'multiOptions' => $this->optionallyAddFromEnum($enum), + 'class' => 'autosubmit' + )); + + return $this; + } + + protected function addDescriptionElement() + { + $this->addElement('textarea', 'description', array( + 'label' => $this->translate('Description'), + 'description' => $this->translate( + 'A meaningful description explaining your users what to expect' + . ' when assigning this set of services' + ), + 'rows' => '3', + 'required' => ! $this->isTemplate(), + )); + + return $this; + } + + protected function addAssignmentElements() + { + if (! $this->hasPermission('director/service_set/apply')) { + return $this; + } + + $this->addAssignFilter([ + 'suggestionContext' => 'HostFilterColumns', + 'description' => $this->translate( + 'This allows you to configure an assignment filter. Please feel' + . ' free to combine as many nested operators as you want. You' + . ' might also want to skip this, define it later and/or just' + . ' add this set of services to single hosts. The "contains"' + . ' operator is valid for arrays only. Please use wildcards and' + . ' the = (equals) operator when searching for partial string' + . ' matches, like in *.example.com' + ) + ]); + + return $this; + } +} diff --git a/application/forms/IcingaServiceVarForm.php b/application/forms/IcingaServiceVarForm.php new file mode 100644 index 0000000..e7ac4a0 --- /dev/null +++ b/application/forms/IcingaServiceVarForm.php @@ -0,0 +1,36 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +/** + * @deprecated + */ +class IcingaServiceVarForm extends DirectorObjectForm +{ + public function setup() + { + $this->addElement('select', 'service_id', array( + 'label' => $this->translate('Service'), + 'description' => $this->translate('The name of the service'), + 'multiOptions' => $this->optionalEnum($this->db->enumServices()), + 'required' => true + )); + + $this->addElement('text', 'varname', array( + 'label' => $this->translate('Name'), + 'description' => $this->translate('service var name') + )); + + $this->addElement('textarea', 'varvalue', array( + 'label' => $this->translate('Value'), + 'description' => $this->translate('service var value') + )); + + $this->addElement('text', 'format', array( + 'label' => $this->translate('Format'), + 'description' => $this->translate('value format') + )); + } +} diff --git a/application/forms/IcingaTemplateChoiceForm.php b/application/forms/IcingaTemplateChoiceForm.php new file mode 100644 index 0000000..31fe610 --- /dev/null +++ b/application/forms/IcingaTemplateChoiceForm.php @@ -0,0 +1,140 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaTemplateChoice; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaTemplateChoiceForm extends DirectorObjectForm +{ + private $choiceType; + + public static function create($type, Db $db) + { + return static::load()->setDb($db)->setChoiceType($type); + } + + public function optionallyLoad($name) + { + if ($name !== null) { + /** @var IcingaTemplateChoice $class - cheating IDE */ + $class = $this->getObjectClassName(); + $this->setObject($class::load($name, $this->getDb())); + } + + return $this; + } + + protected function getObjectClassname() + { + if ($this->className === null) { + return 'Icinga\\Module\\Director\\Objects\\IcingaTemplateChoice' + . ucfirst($this->choiceType); + } + + return $this->className; + } + + public function setChoiceType($type) + { + $this->choiceType = $type; + return $this; + } + + public function setup() + { + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Choice name'), + 'required' => true, + 'description' => $this->translate( + 'This will be shown as a label for the given choice' + ) + )); + + $this->addElement('textarea', 'description', array( + 'label' => $this->translate('Description'), + 'rows' => 4, + 'description' => $this->translate( + 'A detailled description explaining what this choice is all about' + ) + )); + + $this->addElement('extensibleSet', 'members', array( + 'label' => $this->translate('Available choices'), + 'required' => true, + 'description' => $this->translate( + 'Your users will be allowed to choose among those templates' + ), + 'multiOptions' => $this->fetchUnboundTemplates() + )); + + $this->addElement('text', 'min_required', array( + 'label' => $this->translate('Minimum required'), + 'description' => $this->translate( + 'Choosing this many options will be mandatory for this Choice.' + . ' Setting this to zero will leave this Choice optional, setting' + . ' it to one results in a "required" Choice. You can use higher' + . ' numbers to enforce multiple options, this Choice will then turn' + . ' into a multi-selection element.' + ), + 'value' => 0, + )); + + $this->addElement('text', 'max_allowed', array( + 'label' => $this->translate('Allowed maximum'), + 'description' => $this->translate( + 'It will not be allowed to choose more than this many options.' + . ' Setting it to one (1) will result in a drop-down box, a' + . ' higher number will turn this into a multi-selection element.' + ), + 'value' => 1, + )); + + $this->addElement('select', 'required_template', [ + 'label' => $this->translate('Associated Template'), + 'description' => $this->translate( + 'Choose Choice Associated Template' + ), + 'required' => true, + 'multiOptions' => $this->fetchUnboundTemplates(), + ]); + + $this->setButtons(); + } + + protected function fetchUnboundTemplates() + { + /** @var IcingaTemplateChoice $object */ + $object = $this->object(); + $db = $this->getDb()->getDbAdapter(); + $table = $object->getObjectTableName(); + $query = $db->select()->from( + ['o' => $table], + [ + 'k' => 'o.object_name', + 'v' => 'o.object_name', + ] + )->where("o.object_type = 'template'"); + if ($object->hasBeenLoadedFromDb()) { + $query->where( + 'o.template_choice_id IS NULL OR o.template_choice_id = ?', + $object->get('id') + ); + } else { + $query->where('o.template_choice_id IS NULL'); + } + + return $db->fetchPairs($query); + } + + protected function setObjectSuccessUrl() + { + /** @var IcingaTemplateChoice $object */ + $object = $this->object(); + $this->setSuccessUrl( + 'director/templatechoice/' . $object->getObjectshortTableName(), + $object->getUrlParams() + ); + } +} diff --git a/application/forms/IcingaTimePeriodForm.php b/application/forms/IcingaTimePeriodForm.php new file mode 100644 index 0000000..8afcdf3 --- /dev/null +++ b/application/forms/IcingaTimePeriodForm.php @@ -0,0 +1,82 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaTimePeriodForm extends DirectorObjectForm +{ + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->addElement('text', 'object_name', [ + 'label' => $this->translate('Name'), + 'required' => true, + ]); + + $this->addElement('text', 'display_name', [ + 'label' => $this->translate('Display Name'), + ]); + + if ($this->isTemplate()) { + $this->addElement('text', 'update_method', [ + 'label' => $this->translate('Update Method'), + 'value' => 'LegacyTimePeriod', + ]); + } else { + // TODO: I'd like to skip this for objects inheriting from a template + // with a defined update_method. However, unfortunately it's too + // early for $this->object()->getResolvedProperty('update_method'). + // Should be fixed. + $this->addHidden('update_method', 'LegacyTimePeriod'); + } + + $this->addIncludeExclude() + ->addImportsElement() + ->setButtons(); + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addIncludeExclude() + { + $periods = []; + foreach ($this->db->enumTimeperiods() as $id => $period) { + if ($this->object === null || $this->object->get('object_name') !== $period) { + $periods[$period] = $period; + } + } + + if (empty($periods)) { + return $this; + } + + $this->addElement('extensibleSet', 'includes', [ + 'label' => $this->translate('Include period'), + 'multiOptions' => $this->optionalEnum($periods), + 'description' => $this->translate( + 'Include other time periods into this.' + ), + ]); + + $this->addElement('extensibleSet', 'excludes', [ + 'label' => $this->translate('Exclude period'), + 'multiOptions' => $this->optionalEnum($periods), + 'description' => $this->translate( + 'Exclude other time periods from this.' + ), + ]); + + $this->optionalBoolean( + 'prefer_includes', + $this->translate('Prefer includes'), + $this->translate('Whether to prefer timeperiods includes or excludes. Default to true.') + ); + + return $this; + } +} diff --git a/application/forms/IcingaTimePeriodRangeForm.php b/application/forms/IcingaTimePeriodRangeForm.php new file mode 100644 index 0000000..977684e --- /dev/null +++ b/application/forms/IcingaTimePeriodRangeForm.php @@ -0,0 +1,105 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaTimePeriod; +use Icinga\Module\Director\Objects\IcingaTimePeriodRange; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaTimePeriodRangeForm extends DirectorObjectForm +{ + /** + * @var IcingaTimePeriod + */ + private $period; + + public function setup() + { + $this->addHidden('timeperiod_id', $this->period->get('id')); + $this->addElement('text', 'range_key', array( + 'label' => $this->translate('Day(s)'), + 'description' => $this->translate( + 'Might be monday, tuesday or 2016-01-28 - have a look at the documentation for more examples' + ), + )); + + $this->addElement('text', 'range_value', array( + 'label' => $this->translate('Timerperiods'), + 'description' => $this->translate( + 'One or more time periods, e.g. 00:00-24:00 or 00:00-09:00,17:00-24:00' + ), + )); + + $this->setButtons(); + } + + public function setTimePeriod(IcingaTimePeriod $period) + { + $this->period = $period; + $this->setDb($period->getConnection()); + return $this; + } + + /** + * @param IcingaTimePeriodRange $object + */ + protected function deleteObject($object) + { + $key = $object->get('range_key'); + $period = $this->period; + $period->ranges()->remove($key); + $period->store(); + $msg = sprintf( + 'Time period range "%s" has been removed from %s', + $key, + $period->getObjectName() + ); + + $url = $this->getSuccessUrl()->without( + ['range', 'range_type'] + ); + + $this->setSuccessUrl($url); + $this->redirectOnSuccess($msg); + } + + public function onSuccess() + { + $object = $this->object(); + if ($object->hasBeenModified()) { + $this->period->ranges()->setRange( + $this->getValue('range_key'), + $this->getValue('range_value') + ); + } + + if ($this->period->hasBeenModified()) { + if (! $object->hasBeenLoadedFromDb()) { + $this->setHttpResponseCode(201); + } + + $msg = sprintf( + $object->hasBeenLoadedFromDb() + ? $this->translate('The %s has successfully been stored') + : $this->translate('A new %s has successfully been created'), + $this->translate($this->getObjectShortClassName()) + ); + + $this->period->store($this->db); + } else { + if ($this->isApiRequest()) { + $this->setHttpResponseCode(304); + } + $msg = $this->translate('No action taken, object has not been modified'); + } + if ($object instanceof IcingaObject) { + $this->setSuccessUrl( + 'director/' . strtolower($this->getObjectShortClassName()), + $object->getUrlParams() + ); + } + + $this->redirectOnSuccess($msg); + } +} diff --git a/application/forms/IcingaUserForm.php b/application/forms/IcingaUserForm.php new file mode 100644 index 0000000..bff2252 --- /dev/null +++ b/application/forms/IcingaUserForm.php @@ -0,0 +1,214 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaUserForm extends DirectorObjectForm +{ + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->addObjectTypeElement(); + if (! $this->hasObjectType()) { + $this->groupMainProperties(); + return; + } + + if ($this->isTemplate()) { + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('User template name'), + 'required' => true, + 'description' => $this->translate('Name for the Icinga user template you are going to create') + )); + } else { + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Username'), + 'required' => true, + 'description' => $this->translate('Name for the Icinga user object you are going to create') + )); + } + + if (! $this->isTemplate()) { + $this->addElement('text', 'email', array( + 'label' => $this->translate('Email'), + 'description' => $this->translate('The Email address of the user.') + )); + + $this->addElement('text', 'pager', array( + 'label' => $this->translate('Pager'), + 'description' => $this->translate('The pager address of the user.') + )); + } + + $this->addGroupsElement() + ->addImportsElement() + ->addDisplayNameElement() + ->addEnableNotificationsElement() + ->addDisabledElement() + ->addZoneElements() + ->addPeriodElement() + ->addEventFilterElements() + ->groupMainProperties() + ->setButtons(); + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addZoneElements() + { + if (! $this->isTemplate()) { + return $this; + } + + $this->addZoneElement(); + $this->addDisplayGroup(array('zone_id'), 'clustering', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_CLUSTERING, + 'legend' => $this->translate('Zone settings') + )); + + return $this; + } + + /** + * @return $this + */ + protected function addEnableNotificationsElement() + { + $this->optionalBoolean( + 'enable_notifications', + $this->translate('Send notifications'), + $this->translate('Whether to send notifications for this user') + ); + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addGroupsElement() + { + $groups = $this->enumUsergroups(); + + if (empty($groups)) { + return $this; + } + + $this->addElement('extensibleSet', 'groups', array( + 'label' => $this->translate('Groups'), + 'multiOptions' => $this->optionallyAddFromEnum($groups), + 'positional' => false, + 'description' => $this->translate( + 'User groups that should be directly assigned to this user. Groups can be useful' + . ' for various reasons. You might prefer to send notifications to groups instead of' + . ' single users' + ) + )); + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addDisplayNameElement() + { + if ($this->isTemplate()) { + return $this; + } + + $this->addElement('text', 'display_name', array( + 'label' => $this->translate('Display name'), + 'description' => $this->translate( + 'Alternative name for this user. In case your object name is a' + . ' username, this could be the full name of the corresponding person' + ) + )); + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addPeriodElement() + { + $periods = $this->db->enumTimeperiods(); + if (empty($periods)) { + return $this; + } + + $this->addElement( + 'select', + 'period_id', + array( + 'label' => $this->translate('Time period'), + 'description' => $this->translate( + 'The name of a time period which determines when notifications' + . ' to this User should be triggered. Not set by default.' + ), + 'multiOptions' => $this->optionalEnum($periods), + ) + ); + + return $this; + } + + /** + * @throws \Zend_Form_Exception + */ + protected function groupObjectDefinition() + { + $elements = array( + 'object_type', + 'object_name', + 'display_name', + 'imports', + 'groups', + 'email', + 'pager', + 'period_id', + 'enable_notifications', + 'disabled', + ); + $this->addDisplayGroup($elements, 'object_definition', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_OBJECT_DEFINITION, + 'legend' => $this->translate('User properties') + )); + } + + /** + * @return array + */ + protected function enumUsergroups() + { + $db = $this->db->getDbAdapter(); + $select = $db->select()->from( + 'icinga_usergroup', + array( + 'name' => 'object_name', + 'display' => 'COALESCE(display_name, object_name)' + ) + )->where('object_type = ?', 'object')->order('display'); + + return $db->fetchPairs($select); + } +} diff --git a/application/forms/IcingaUserGroupForm.php b/application/forms/IcingaUserGroupForm.php new file mode 100644 index 0000000..d9706b4 --- /dev/null +++ b/application/forms/IcingaUserGroupForm.php @@ -0,0 +1,47 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaUserGroupForm extends DirectorObjectForm +{ + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->addHidden('object_type', 'object'); + + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Usergroup'), + 'required' => true, + 'description' => $this->translate('Icinga object name for this user group') + )); + + $this->addGroupDisplayNameElement() + ->addZoneElements() + ->groupMainProperties() + ->setButtons(); + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addZoneElements() + { + $this->addZoneElement(true); + $this->addDisplayGroup(['zone_id'], 'clustering', [ + 'decorators' => [ + 'FormElements', + ['HtmlTag', ['tag' => 'dl']], + 'Fieldset', + ], + 'order' => self::GROUP_ORDER_CLUSTERING, + 'legend' => $this->translate('Zone settings') + ]); + + return $this; + } +} diff --git a/application/forms/IcingaZoneForm.php b/application/forms/IcingaZoneForm.php new file mode 100644 index 0000000..bf27cae --- /dev/null +++ b/application/forms/IcingaZoneForm.php @@ -0,0 +1,43 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class IcingaZoneForm extends DirectorObjectForm +{ + public function setup() + { + $this->addHidden('object_type', 'object'); + + $this->addElement('text', 'object_name', array( + 'label' => $this->translate('Zone name'), + 'required' => true, + 'description' => $this->translate( + 'Name for the Icinga zone you are going to create' + ) + )); + + $this->addElement('select', 'is_global', array( + 'label' => $this->translate('Global zone'), + 'description' => $this->translate( + 'Whether this zone should be available everywhere. Please note that' + . ' it rarely leads to the desired result when you try to distribute' + . ' global zones in distrubuted environments' + ), + 'multiOptions' => array( + 'n' => $this->translate('No'), + 'y' => $this->translate('Yes'), + ), + 'required' => true, + )); + + $this->addElement('select', 'parent_id', array( + 'label' => $this->translate('Parent Zone'), + 'description' => $this->translate('Chose an (optional) parent zone'), + 'multiOptions' => $this->optionalEnum($this->db->enumZones()), + )); + + $this->setButtons(); + } +} diff --git a/application/forms/ImportCheckForm.php b/application/forms/ImportCheckForm.php new file mode 100644 index 0000000..31c9781 --- /dev/null +++ b/application/forms/ImportCheckForm.php @@ -0,0 +1,50 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class ImportCheckForm extends DirectorForm +{ + /** @var ImportSource */ + protected $source; + + public function setImportSource(ImportSource $source) + { + $this->source = $source; + return $this; + } + + public function setup() + { + $this->submitLabel = false; + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Check for changes'), + 'decorators' => ['ViewHelper'] + ]); + } + + public function onSuccess() + { + $source = $this->source; + if ($source->checkForChanges()) { + $this->setSuccessMessage( + $this->translate('This Import Source provides modified data') + ); + } else { + $this->setSuccessMessage( + $this->translate( + 'Nothing to do, data provided by this Import Source' + . " didn't change since the last import run" + ) + ); + } + + if ($source->get('import_state') === 'failing') { + $this->addError($this->translate('Checking this Import Source failed')); + } else { + parent::onSuccess(); + } + } +} diff --git a/application/forms/ImportRowModifierForm.php b/application/forms/ImportRowModifierForm.php new file mode 100644 index 0000000..9e53bd9 --- /dev/null +++ b/application/forms/ImportRowModifierForm.php @@ -0,0 +1,182 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Exception; +use Icinga\Application\Hook; +use Icinga\Exception\ConfigurationError; +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use RuntimeException; + +class ImportRowModifierForm extends DirectorObjectForm +{ + /** @var ImportSource */ + protected $source; + + /** @var ImportSourceHook */ + protected $importSource; + + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->addHidden('source_id', $this->source->id); + + $this->addElement('text', 'property_name', array( + 'label' => $this->translate('Property'), + 'description' => $this->translate( + 'Please start typing for a list of suggestions. Dots allow you to access nested' + . ' properties: column.some.key. Such nested properties cannot be modified in-place,' + . ' but you can store the modified value to a new "target property"' + ), + 'required' => true, + 'class' => 'autosubmit director-suggest', + 'data-suggestion-context' => 'importsourceproperties!' . $this->source->id, + )); + + $this->addElement('text', 'target_property', [ + 'label' => $this->translate('Target property'), + 'description' => $this->translate( + 'You might want to write the modified value to another (new) property.' + . ' This property name can be defined here, the original property would' + . ' remain unmodified. Please leave this blank in case you just want to' + . ' modify the value of a specific property' + ), + ]); + + $this->addElement('textarea', 'description', [ + 'label' => $this->translate('Description'), + 'description' => $this->translate( + 'An extended description for this Import Row Modifier. This should explain' + . " it's purpose and why it has been put in place at all." + ), + 'rows' => '3', + ]); + + $error = false; + try { + $mods = $this->enumModifiers(); + } catch (Exception $e) { + $error = $e->getMessage(); + $mods = $this->optionalEnum([]); + } + + $this->addElement('select', 'provider_class', [ + 'label' => $this->translate('Modifier'), + 'required' => true, + 'description' => $this->translate( + 'A property modifier allows you to modify a specific property at import time' + ), + 'multiOptions' => $this->optionalEnum($mods), + 'class' => 'autosubmit', + ]); + if ($error) { + $this->getElement('provider_class')->addError($error); + } + + try { + if ($class = $this->getSentValue('provider_class')) { + if ($class && array_key_exists($class, $mods)) { + $this->addSettings($class); + } + } elseif ($class = $this->object()->get('provider_class')) { + $this->addSettings($class); + } + + // TODO: next line looks like obsolete duplicate code to me + $this->addSettings(); + } catch (Exception $e) { + $this->getElement('provider_class')->addError($e->getMessage()); + } + + foreach ($this->object()->getSettings() as $key => $val) { + if ($el = $this->getElement($key)) { + $el->setValue($val); + } + } + + $this->setButtons(); + } + + public function getSetting($name, $default = null) + { + if ($this->hasBeenSent()) { + $value = $this->getSentValue($name); + if ($value !== null) { + return $value; + } + } + if ($this->isNew()) { + $value = $this->getElement($name)->getValue(); + if ($value === null) { + return $default; + } + + return $value; + } + + return $this->object()->getSetting($name, $default); + } + + /** + * @return ImportSourceHook + * @throws ConfigurationError + */ + protected function getImportSource() + { + if ($this->importSource === null) { + $this->importSource = ImportSourceHook::loadByName( + $this->source->get('source_name'), + $this->db + ); + } + + return $this->importSource; + } + + protected function enumModifiers() + { + /** @var PropertyModifierHook[] $hooks */ + $hooks = Hook::all('Director\\PropertyModifier'); + $enum = []; + foreach ($hooks as $hook) { + $enum[get_class($hook)] = $hook->getName(); + } + + asort($enum); + + return $enum; + } + + /** + * @param null $class + */ + protected function addSettings($class = null) + { + if ($class === null) { + $class = $this->getValue('provider_class'); + } + + if ($class !== null) { + if (! class_exists($class)) { + throw new RuntimeException(sprintf( + 'The hooked class "%s" for this property modifier does no longer exist', + $class + )); + } + + $class::addSettingsFormFields($this); + } + } + + public function setSource(ImportSource $source) + { + $this->source = $source; + + return $this; + } +} diff --git a/application/forms/ImportRunForm.php b/application/forms/ImportRunForm.php new file mode 100644 index 0000000..9f6494f --- /dev/null +++ b/application/forms/ImportRunForm.php @@ -0,0 +1,50 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class ImportRunForm extends DirectorForm +{ + /** @var ImportSource */ + protected $source; + + public function setImportSource(ImportSource $source) + { + $this->source = $source; + return $this; + } + + public function setup() + { + $this->submitLabel = false; + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Trigger Import Run'), + 'decorators' => ['ViewHelper'] + ]); + } + + public function onSuccess() + { + $source = $this->source; + if ($source->runImport()) { + $this->setSuccessMessage( + $this->translate('Imported new data from this Import Source') + ); + } else { + $this->setSuccessMessage( + $this->translate( + 'Nothing to do, data provided by this Import Source' + . " didn't change since the last import run" + ) + ); + } + + if ($source->get('import_state') === 'failing') { + $this->addError($this->translate('Triggering this Import Source failed')); + } else { + parent::onSuccess(); + } + } +} diff --git a/application/forms/ImportSourceForm.php b/application/forms/ImportSourceForm.php new file mode 100644 index 0000000..b547a32 --- /dev/null +++ b/application/forms/ImportSourceForm.php @@ -0,0 +1,163 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Web\Hook; + +class ImportSourceForm extends DirectorObjectForm +{ + public function setup() + { + $this->addElement('text', 'source_name', array( + 'label' => $this->translate('Import source name'), + 'description' => $this->translate( + 'A short name identifying this import source. Use something meaningful,' + . ' like "Hosts from Puppet", "Users from Active Directory" or similar' + ), + 'required' => true, + )); + + $this->addElement('textarea', 'description', array( + 'label' => $this->translate('Description'), + 'description' => $this->translate( + 'An extended description for this Import Source. This should explain' + . " what kind of data you're going to import from this source." + ), + 'rows' => '3', + )); + + $this->addElement('select', 'provider_class', array( + 'label' => $this->translate('Source Type'), + 'required' => true, + 'multiOptions' => $this->optionalEnum($this->enumSourceTypes()), + 'description' => $this->translate( + 'These are different data providers fetching data from various sources.' + . ' You didn\'t find what you\'re looking for? Import sources are implemented' + . ' as a hook in Director, so you might find (or write your own) Icinga Web 2' + . ' module fetching data from wherever you want' + ), + 'class' => 'autosubmit' + )); + + $this->addSettings(); + $this->setButtons(); + } + + public function getSentOrObjectSetting($name, $default = null) + { + if ($this->hasObject()) { + $value = $this->getSentValue($name); + if ($value === null) { + /** @var ImportSource $object */ + $object = $this->getObject(); + + return $object->getSetting($name, $default); + } else { + return $value; + } + } else { + return $this->getSentValue($name, $default); + } + } + + public function hasChangedSetting($name) + { + if ($this->hasBeenSent() && $this->hasObject()) { + /** @var ImportSource $object */ + $object = $this->getObject(); + return $object->getStoredSetting($name) + !== $this->getSentValue($name); + } else { + return false; + } + } + + protected function addSettings() + { + if (! ($class = $this->getProviderClass())) { + return; + } + + $defaultKeyCol = $this->getDefaultKeyColumnName(); + + $this->addElement('text', 'key_column', array( + 'label' => $this->translate('Key column name'), + 'description' => $this->translate( + 'This must be a column containing unique values like hostnames. Unless otherwise' + . ' specified this will then be used as the object_name for the syncronized' + . ' Icinga object. Especially when getting started with director please make' + . ' sure to strictly follow this rule. Duplicate values for this column on different' + . ' rows will trigger a failure, your import run will not succeed. Please pay attention' + . ' when synching services, as "purge" will only work correctly with a key_column' + . ' corresponding to host!name. Check the "Combine" property modifier in case your' + . ' data source cannot provide such a field' + ), + 'placeholder' => $defaultKeyCol, + 'required' => $defaultKeyCol === null, + )); + + if (array_key_exists($class, $this->enumSourceTypes())) { + $class::addSettingsFormFields($this); + foreach ($this->object()->getSettings() as $key => $val) { + if ($el = $this->getElement($key)) { + $el->setValue($val); + } + } + } + } + + protected function getDefaultKeyColumnName() + { + if (! ($class = $this->getProviderClass())) { + return null; + } + + if (! class_exists($class)) { + return null; + } + + return $class::getDefaultKeyColumnName(); + } + + protected function getProviderClass() + { + if ($this->hasBeenSent()) { + $class = $this->getRequest()->getPost('provider_class'); + } else { + if (! ($class = $this->object()->get('provider_class'))) { + return null; + } + } + + return $class; + } + + public function onSuccess() + { + if (! $this->getValue('key_column')) { + if ($default = $this->getDefaultKeyColumnName()) { + $this->setElementValue('key_column', $default); + $this->object()->set('key_column', $default); + } + } + + parent::onSuccess(); + } + + protected function enumSourceTypes() + { + /** @var ImportSourceHook[] $hooks */ + $hooks = Hook::all('Director\\ImportSource'); + + $enum = array(); + foreach ($hooks as $hook) { + $enum[get_class($hook)] = $hook->getName(); + } + asort($enum); + + return $enum; + } +} diff --git a/application/forms/KickstartForm.php b/application/forms/KickstartForm.php new file mode 100644 index 0000000..0079cfb --- /dev/null +++ b/application/forms/KickstartForm.php @@ -0,0 +1,482 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Exception; +use Icinga\Application\Config; +use Icinga\Data\ResourceFactory; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Migrations; +use Icinga\Module\Director\Objects\IcingaEndpoint; +use Icinga\Module\Director\KickstartHelper; +use Icinga\Module\Director\Web\Form\DirectorForm; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; + +class KickstartForm extends DirectorForm +{ + private $config; + + private $storeConfigLabel; + + private $createDbLabel; + + private $migrateDbLabel; + + /** @var IcingaEndpoint */ + private $endpoint; + + private $dbResourceName; + + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->storeConfigLabel = $this->translate('Store configuration'); + $this->createDbLabel = $this->translate('Create database schema'); + $this->migrateDbLabel = $this->translate('Apply schema migrations'); + + if ($this->dbResourceName === null) { + $this->addResourceConfigElements(); + $this->addResourceDisplayGroup(); + + if (!$this->config()->get('db', 'resource') + || ($this->config()->get('db', 'resource') !== $this->getResourceName())) { + return; + } + } + + if (!$this->hasBeenSent() && !$this->tryDbConnection()) { + return; + } + + if (!$this->migrations()->hasSchema()) { + $this->addHtmlHint($this->translate( + 'No database schema has been created yet' + ), array('name' => 'HINT_schema')); + + $this->addResourceDisplayGroup(); + $this->setSubmitLabel($this->createDbLabel); + return; + } + + if ($this->migrations()->hasPendingMigrations()) { + $this->addHtmlHint($this->translate( + 'There are pending database migrations' + ), array('name' => 'HINT_schema')); + + $this->addResourceDisplayGroup(); + $this->setSubmitLabel($this->migrateDbLabel); + return; + } + + if (! $this->endpoint && $this->getDb()->hasDeploymentEndpoint()) { + $hint = Html::sprintf( + $this->translate('Your database looks good, you are ready to %s'), + Link::create( + $this->translate('start working with the Icinga Director'), + 'director', + null, + ['data-base-target' => '_main'] + ) + ); + + $this->addHtmlHint($hint, ['name' => 'HINT_ready']); + $this->getDisplayGroup('config')->addElements( + array($this->getElement('HINT_ready')) + ); + + return; + } + + $this->addResourceDisplayGroup(); + + if ($this->getDb()->hasDeploymentEndpoint()) { + $this->addHtmlHint( + $this->translate( + 'Your configuration looks good. Still, you might want to re-run' + . ' this kickstart wizard to (re-)import modified or new manually' + . ' defined Command definitions or to get fresh new ITL commands' + . ' after an Icinga 2 Core upgrade.' + ), + array('name' => 'HINT_kickstart') + // http://docs.icinga.com/icinga2/latest/doc/module/icinga2/chapter/ + // ... object-types#objecttype-apilistener + ); + } else { + $this->addHtmlHint( + $this->translate( + 'Your installation of Icinga Director has not yet been prepared for' + . ' deployments. This kickstart wizard will assist you with setting' + . ' up the connection to your Icinga 2 server.' + ), + array('name' => 'HINT_kickstart') + // http://docs.icinga.com/icinga2/latest/doc/module/icinga2/chapter/ + // ... object-types#objecttype-apilistener + ); + } + + $this->addElement('text', 'endpoint', array( + 'label' => $this->translate('Endpoint Name'), + 'description' => $this->translate( + 'This is the name of the Endpoint object (and certificate name) you' + . ' created for your ApiListener object. In case you are unsure what' + . ' this means please make sure to read the documentation first' + ), + 'required' => true, + )); + + $this->addElement('text', 'host', array( + 'label' => $this->translate('Icinga Host'), + 'description' => $this->translate( + 'IP address / hostname of your Icinga node. Please note that this' + . ' information will only be used for the very first connection to' + . ' your Icinga instance. The Director then relies on a correctly' + . ' configured Endpoint object. Correctly configures means that either' + . ' it\'s name is resolvable or that it\'s host property contains' + . ' either an IP address or a resolvable host name. Your Director must' + . ' be able to reach this endpoint' + ), + 'required' => false, + )); + + $this->addElement('text', 'port', array( + 'label' => $this->translate('Port'), + 'value' => '5665', + 'description' => $this->translate( + 'The port you are going to use. The default port 5665 will be used' + . ' if none is set' + ), + 'required' => false, + )); + + $this->addElement('text', 'username', array( + 'label' => $this->translate('API user'), + 'description' => $this->translate( + 'Your Icinga 2 API username' + ), + 'required' => true, + )); + + $this->addElement('password', 'password', array( + 'label' => $this->translate('Password'), + 'description' => $this->translate( + 'The corresponding password' + ), + 'required' => true, + )); + + if ($ep = $this->endpoint) { + $user = $ep->getApiUser(); + $this->setDefaults(array( + 'endpoint' => $ep->get('object_name'), + 'host' => $ep->get('host'), + 'port' => $ep->get('port'), + 'username' => $user->get('object_name'), + 'password' => $user->get('password'), + )); + + if (! empty($user->password)) { + $this->getElement('password')->setAttrib( + 'placeholder', + '(use stored password)' + )->setRequired(false); + } + } + + $this->addKickstartDisplayGroup(); + $this->setSubmitLabel($this->translate('Run import')); + } + + /** + * @throws \Icinga\Exception\ConfigurationError + */ + protected function onSetup() + { + if ($this->hasBeenSubmitted()) { + // Do not hinder the form from being stored + return; + } + + $this->tryDbConnection(); + } + + /** + * @throws \Zend_Form_Exception + */ + protected function addResourceConfigElements() + { + $config = $this->config(); + $resources = $this->enumResources(); + + if (!$this->getResourceName()) { + $this->addHtmlHint($this->translate( + 'No database resource has been configured yet. Please choose a' + . ' resource to complete your config' + ), array('name' => 'HINT_no_resource')); + } + + $this->addElement('select', 'resource', array( + 'required' => true, + 'label' => $this->translate('DB Resource'), + 'multiOptions' => $this->optionalEnum($resources), + 'class' => 'autosubmit', + 'value' => $config->get('db', 'resource') + )); + + if (empty($resources)) { + $this->getElement('resource')->addError( + $this->translate('This has to be a MySQL or PostgreSQL database') + ); + + $this->addHtmlHint(Html::sprintf( + $this->translate('Please click %s to create new DB resources'), + Link::create( + $this->translate('here'), + 'config/resource', + null, + ['data-base-target' => '_main'] + ) + )); + } + + $this->setSubmitLabel($this->storeConfigLabel); + } + + /** + * @throws \Zend_Form_Exception + */ + protected function addResourceDisplayGroup() + { + if ($this->dbResourceName !== null) { + return; + } + + $elements = array( + 'HINT_no_resource', + 'resource', + 'HINT_ready', + 'HINT_schema', + 'HINT_db_perms', + 'HINT_config_store' + ); + + $this->addDisplayGroup($elements, 'config', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => 40, + 'legend' => $this->translate('Database backend') + )); + } + + /** + * @throws \Zend_Form_Exception + */ + protected function addKickstartDisplayGroup() + { + $elements = array( + 'HINT_kickstart', 'endpoint', 'host', 'port', 'username', 'password' + ); + + $this->addDisplayGroup($elements, 'wizard', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => 60, + 'legend' => $this->translate('Kickstart Wizard') + )); + } + + /** + * @return bool + * @throws \Zend_Form_Exception + */ + protected function storeResourceConfig() + { + $config = $this->config(); + $value = $this->getValue('resource'); + + $config->setSection('db', array('resource' => $value)); + + try { + $config->saveIni(); + $this->setSuccessMessage($this->translate('Configuration has been stored')); + + return true; + } catch (Exception $e) { + $this->getElement('resource')->addError( + sprintf( + $this->translate( + 'Unable to store the configuration to "%s". Please check' + . ' file permissions or manually store the content shown below' + ), + $config->getConfigFile() + ) + ); + $this->addHtmlHint( + Html::tag('pre', null, (string) $config), + array('name' => 'HINT_config_store') + ); + + $this->getDisplayGroup('config')->addElements( + array($this->getElement('HINT_config_store')) + ); + $this->removeElement('HINT_ready'); + + return false; + } + } + + public function setEndpoint(IcingaEndpoint $endpoint) + { + $this->endpoint = $endpoint; + return $this; + } + + /** + * @throws \Icinga\Exception\ProgrammingError + * @throws \Zend_Form_Exception + */ + public function onSuccess() + { + if ($this->getSubmitLabel() === $this->storeConfigLabel) { + if ($this->storeResourceConfig()) { + parent::onSuccess(); + } else { + return; + } + } + + if ($this->getSubmitLabel() === $this->createDbLabel + || $this->getSubmitLabel() === $this->migrateDbLabel) { + $this->migrations()->applyPendingMigrations(); + parent::onSuccess(); + } + + $values = $this->getValues(); + if ($this->endpoint && empty($values['password'])) { + $values['password'] = $this->endpoint->getApiUser()->password; + } + + $kickstart = new KickstartHelper($this->getDb()); + unset($values['resource']); + $kickstart->setConfig($values)->run(); + + parent::onSuccess(); + } + + public function setDbResourceName($name) + { + $this->dbResourceName = $name; + + return $this; + } + + protected function getResourceName() + { + if ($this->dbResourceName !== null) { + return $this->dbResourceName; + } + + if ($this->hasBeenSent()) { + $resource = $this->getSentValue('resource'); + $resources = $this->enumResources(); + if (in_array($resource, $resources)) { + return $resource; + } else { + return null; + } + } else { + return $this->config()->get('db', 'resource'); + } + } + + public function getDb() + { + return Db::fromResourceName($this->getResourceName()); + } + + protected function getResource() + { + return ResourceFactory::create($this->getResourceName()); + } + + /** + * @return Migrations + */ + protected function migrations() + { + return new Migrations($this->getDb()); + } + + public function setModuleConfig(Config $config) + { + $this->config = $config; + return $this; + } + + protected function config() + { + if ($this->config === null) { + $this->config = Config::module('director'); + } + + return $this->config; + } + + protected function enumResources() + { + $resources = array(); + $allowed = array('mysql', 'pgsql'); + + foreach (ResourceFactory::getResourceConfigs() as $name => $resource) { + if ($resource->get('type') === 'db' && in_array($resource->get('db'), $allowed)) { + $resources[$name] = $name; + } + } + + return $resources; + } + + protected function tryDbConnection() + { + if ($resourceName = $this->getResourceName()) { + $resourceConfig = ResourceFactory::getResourceConfig($resourceName); + if (!isset($resourceConfig->charset) + || !in_array($resourceConfig->charset, array('utf8', 'utf8mb4', 'UTF8', 'UTF-8')) + ) { + if ($resource = $this->getElement('resource')) { + $resource->addError('Please change the encoding for the director database to utf8'); + } else { + $this->addError('Please change the encoding for the director database to utf8'); + } + } + + $resource = $this->getResource(); + $db = $resource->getDbAdapter(); + + try { + $db->fetchOne('SELECT 1'); + return true; + } catch (Exception $e) { + $this->getElement('resource') + ->addError('Could not connect to database: ' . $e->getMessage()); + + $hint = $this->translate( + 'Please make sure that your database exists and your user has' + . ' been granted enough permissions' + ); + + $this->addHtmlHint($hint, array('name' => 'HINT_db_perms')); + } + } + + return false; + } +} diff --git a/application/forms/RemoveLinkForm.php b/application/forms/RemoveLinkForm.php new file mode 100644 index 0000000..6f0c7cc --- /dev/null +++ b/application/forms/RemoveLinkForm.php @@ -0,0 +1,59 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use gipfl\IcingaWeb2\Icon; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class RemoveLinkForm extends DirectorForm +{ + private $label; + + private $title; + + private $onSuccessAction; + + public function __construct($label, $title, $action, $params = []) + { + // Required to detect the right instance + $this->formName = 'RemoveSet' . sha1(json_encode($params)); + parent::__construct([ + 'style' => 'float: right', + 'data-base-target' => '_self' + ]); + $this->label = $label; + $this->title = $title; + foreach ($params as $name => $value) { + $this->addHidden($name, $value); + } + $this->setAction($action); + } + + public function runOnSuccess($action) + { + $this->onSuccessAction = $action; + + return $this; + } + + public function setup() + { + $this->setAttrib('class', 'inline'); + $this->addHtml(Icon::create('cancel')); + $this->addSubmitButton($this->label, [ + 'class' => 'link-button', + 'title' => $this->title, + ]); + } + + public function onSuccess() + { + if ($this->onSuccessAction !== null) { + $func = $this->onSuccessAction; + $func(); + $this->redirectOnSuccess( + $this->translate('Service Set has been removed') + ); + } + } +} diff --git a/application/forms/RestoreBasketForm.php b/application/forms/RestoreBasketForm.php new file mode 100644 index 0000000..90d5b38 --- /dev/null +++ b/application/forms/RestoreBasketForm.php @@ -0,0 +1,77 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Application\Config; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\BasketSnapshot; +use Icinga\Module\Director\Web\Controller\Extension\DirectorDb; +use Icinga\Module\Director\Web\Form\QuickForm; + +class RestoreBasketForm extends QuickForm +{ + use DirectorDb; + + /** @var BasketSnapshot */ + private $snapshot; + + public function setSnapshot(BasketSnapshot $snapshot) + { + $this->snapshot = $snapshot; + + return $this; + } + + /** + * @codingStandardsIgnoreStart + * @return Auth + */ + protected function Auth() + { + return Auth::getInstance(); + } + + /** + * @return Config + */ + protected function Config() + { + // @codingStandardsIgnoreEnd + return Config::module('director'); + } + + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $allowedDbs = $this->listAllowedDbResourceNames(); + $this->addElement('select', 'target_db', [ + 'label' => $this->translate('Target DB'), + 'description' => $this->translate('Restore to this target Director DB'), + 'multiOptions' => $allowedDbs, + 'value' => $this->getRequest()->getParam('target_db', $this->getFirstDbResourceName()), + 'class' => 'autosubmit', + ]); + + $this->setSubmitLabel($this->translate('Restore')); + } + + public function getDb() + { + return Db::fromResourceName($this->getValue('target_db')); + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + public function onSuccess() + { + $this->snapshot->restoreTo($this->getDb()); + $this->setSuccessUrl($this->getSuccessUrl()->with('target_db', $this->getValue('target_db'))); + $this->setSuccessMessage(sprintf('Restored to %s', $this->getValue('target_db'))); + + parent::onSuccess(); + } +} diff --git a/application/forms/RestoreObjectForm.php b/application/forms/RestoreObjectForm.php new file mode 100644 index 0000000..e665d65 --- /dev/null +++ b/application/forms/RestoreObjectForm.php @@ -0,0 +1,92 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Exception\NotFoundError; +use Icinga\Exception\NotImplementedError; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class RestoreObjectForm extends DirectorForm +{ + /** @var IcingaObject */ + protected $object; + + public function setup() + { + $this->addSubmitButton($this->translate('Restore former object')); + } + + public function onSuccess() + { + $object = $this->object; + $name = $object->getObjectName(); + $db = $this->db; + + $keyParams = $object->getKeyParams(); + + if ($object->supportsApplyRules() && $object->get('object_type') === 'apply') { + // TODO: not all apply should be considered unique by name + object_type + $query = $db->getDbAdapter() + ->select() + ->from($object->getTableName()) + ->where('object_type = ?', 'apply') + ->where('object_name = ?', $name); + + $rules = $object::loadAll($db, $query); + + if (empty($rules)) { + $existing = null; + } elseif (count($rules) === 1) { + $existing = current($rules); + } else { + // TODO: offer drop down? + throw new NotImplementedError( + "Found multiple apply rule matching name '%s', can not restore!", + $name + ); + } + } else { + try { + $existing = $object::load($keyParams, $db); + } catch (NotFoundError $e) { + $existing = null; + } + } + + if ($existing !== null) { + $typeExisting = $existing->get('object_type'); + $typeObject = $object->get('object_type'); + if ($typeExisting !== $typeObject) { + // Not sure when that may occur + throw new NotImplementedError( + 'Found existing object has a mismatching object_type: %s != %s', + $typeExisting, + $typeObject + ); + } + + $existing->replaceWith($object); + + if ($existing->hasBeenModified()) { + $msg = $this->translate('Object has been restored'); + $existing->store(); + } else { + $msg = $this->translate( + 'Nothing to do, restore would not modify the current object' + ); + } + } else { + $msg = $this->translate('Object has been re-created'); + $object->store($db); + } + + $this->redirectOnSuccess($msg); + } + + public function setObject(IcingaObject $object) + { + $this->object = $object; + return $this; + } +} diff --git a/application/forms/SelfServiceSettingsForm.php b/application/forms/SelfServiceSettingsForm.php new file mode 100644 index 0000000..4471bcf --- /dev/null +++ b/application/forms/SelfServiceSettingsForm.php @@ -0,0 +1,306 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Exception; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Settings; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class SelfServiceSettingsForm extends DirectorForm +{ + /** @var Settings */ + protected $settings; + + public function setup() + { + $settings = $this->settings; + $this->addElement('select', 'agent_name', [ + 'label' => $this->translate('Host Name'), + 'description' => $this->translate( + 'What to use as your Icinga 2 Agent\'s Host Name' + ), + 'multiOptions' => [ + 'fqdn' => $this->translate('Fully qualified domain name (FQDN)'), + 'hostname' => $this->translate('Host name (local part, without domain)'), + ], + 'value' => $settings->getStoredOrDefaultValue('self-service/agent_name') + ]); + + $this->addElement('select', 'transform_hostname', [ + 'label' => $this->translate('Transform Host Name'), + 'description' => $this->translate( + 'Whether to adjust your host name' + ), + 'multiOptions' => [ + '0' => $this->translate('Do not transform at all'), + '1' => $this->translate('Transform to lowercase'), + '2' => $this->translate('Transform to uppercase'), + ], + 'value' => $settings->getStoredOrDefaultValue('self-service/transform_hostname') + ]); + $this->addElement('select', 'resolve_parent_host', [ + 'label' => $this->translate('Transform Parent Host to IP'), + 'description' => $this->translate( + 'This is only important in case your master/satellite nodes do not' + . ' have IP addresses as their "host" property. The Agent can be' + . ' told to issue related DNS lookups on it\' own' + ), + 'multiOptions' => [ + '0' => $this->translate("Don't care, my host settings are fine"), + '1' => $this->translate('My Agents should use DNS to look up Endpoint names'), + ], + 'value' => $settings->getStoredOrDefaultValue('self-service/resolve_parent_host') + ]); + + $this->addElement('extensibleSet', 'global_zones', [ + 'label' => $this->translate('Global Zones'), + 'description' => $this->translate( + 'To ensure downloaded packages are build by the Icinga Team' + . ' and not compromised by third parties, you will be able' + . ' to provide an array of SHA1 hashes here. In case you have' + . ' defined any hashses, the module will not continue with' + . ' updating / installing the Agent in case the SHA1 hash of' + . ' the downloaded MSI package is not matching one of the' + . ' provided hashes of this setting' + ), + 'multiOptions' => $this->enumGlobalZones(), + 'value' => $settings->getStoredOrDefaultValue('self-service/global_zones'), + ]); + + $this->addElement('select', 'download_type', [ + 'label' => $this->translate('Installation Source'), + 'description' => $this->translate( + 'You might want to let the generated Powershell script install' + . ' the Icinga 2 Agent in an automated way. If so, please choose' + . ' where your Windows nodes should fetch the Agent installer' + ), + 'multiOptions' => [ + null => $this->translate('- no automatic installation -'), + // TODO: not yet + // 'director' => $this->translate('Download via the Icinga Director'), + 'icinga' => $this->translate('Download from packages.icinga.com'), + 'url' => $this->translate('Download from a custom url'), + 'file' => $this->translate('Use a local file or network share'), + ], + 'value' => $settings->getStoredOrDefaultValue('self-service/download_type'), + 'class' => 'autosubmit' + ]); + + $downloadType = $this->getSentValue( + 'download_type', + $settings->getStoredOrDefaultValue('self-service/download_type') + ); + + if ($downloadType) { + $this->addInstallSettings($downloadType, $settings); + } + + $this->addEventuallyConfiguredBoolean('flush_api_dir', [ + 'label' => $this->translate('Flush API directory'), + 'description' => $this->translate( + 'In case the Icinga Agent will accept configuration from the parent' + . ' Icinga 2 system, it will possibly write data to /var/lib/icinga2/api/*.' + . ' By setting this parameter to true, all content inside the api directory' + . ' will be flushed before an eventual restart of the Icinga 2 Agent' + ), + 'required' => true, + ]); + } + + protected function addInstallSettings($downloadType, Settings $settings) + { + $this->addElement('text', 'download_url', [ + 'label' => $this->translate('Source Path'), + 'description' => $this->translate( + 'Define a download Url or local directory from which the a specific' + . ' Icinga 2 Agent MSI Installer package should be fetched. Please' + . ' ensure to only define the base download Url or Directory. The' + . ' Module will generate the MSI file name based on your operating' + . ' system architecture and the version to install. The Icinga 2 MSI' + . ' Installer name is internally build as follows:' + . ' Icinga2-v[InstallAgentVersion]-[OSArchitecture].msi (full example:' + . ' Icinga2-v2.6.3-x86_64.msi)' + ), + 'value' => $settings->getStoredOrDefaultValue('self-service/download_url'), + ]); + + // TODO: offer to check for available versions + if ($downloadType === 'icinga') { + $el = $this->getElement('download_url'); + $el->setAttrib('disabled', 'disabled'); + $value = 'https://packages.icinga.com/windows/'; + $el->setValue($value); + $this->setSentValue('download_url', $value); + } + if ($downloadType === 'director') { + $el = $this->getElement('download_url'); + $el->setAttrib('disabled', 'disabled'); + + $r = $this->getRequest(); + $scheme = $r->getServer('HTTP_X_FORWARDED_PROTO', $r->getScheme()); + + $value = sprintf( + '%s://%s%s/director/download/windows/', + $scheme, + $r->getHttpHost(), + $this->getRequest()->getBaseUrl() + ); + $el->setValue($value); + $this->setSentValue('download_url', $value); + } + + $this->addElement('text', 'agent_version', [ + 'label' => $this->translate('Agent Version'), + 'description' => $this->translate( + 'In case the Icinga 2 Agent should be automatically installed,' + . ' this has to be a string value like: 2.6.3' + ), + 'value' => $settings->getStoredOrDefaultValue('self-service/agent_version'), + 'required' => true, + ]); + + $hashes = $settings->getStoredOrDefaultValue('self-service/installer_hashes'); + $this->addElement('extensibleSet', 'installer_hashes', [ + 'label' => $this->translate('Installer Hashes'), + 'description' => $this->translate( + 'To ensure downloaded packages are build by the Icinga Team' + . ' and not compromised by third parties, you will be able' + . ' to provide an array of SHA1 hashes here. In case you have' + . ' defined any hashses, the module will not continue with' + . ' updating / installing the Agent in case the SHA1 hash of' + . ' the downloaded MSI package is not matching one of the' + . ' provided hashes of this setting' + ), + 'value' => $hashes, + ]); + + $this->addElement('text', 'icinga_service_user', [ + 'label' => $this->translate('Service User'), + 'description' => $this->translate( + 'The user that should run the Icinga 2 service on Windows.' + ), + 'value' => $settings->getStoredOrDefaultValue('self-service/icinga_service_user'), + ]); + + $this->addEventuallyConfiguredBoolean('allow_updates', [ + 'label' => $this->translate('Allow Updates'), + 'description' => $this->translate( + 'In case the Icinga 2 Agent is already installed on the system,' + . ' this parameter will allow you to configure if you wish to' + . ' upgrade / downgrade to a specified version with the as well.' + ), + 'required' => true, + ]); + + $this->addNscpSettings(); + } + + protected function addNscpSettings() + { + $this->addEventuallyConfiguredBoolean('install_nsclient', [ + 'label' => $this->translate('Install NSClient++'), + 'description' => $this->translate( + 'Also install NSClient++. It can be used through the Icinga Agent' + . ' and comes with a bunch of additional Check Plugins' + ), + 'required' => true, + ]); + /* + * TODO: eventually add those: + if ($settings->get('self-service/install_nsclient') === 'y') { + $params['install_nsclient'] = true; + $this->addBooleanSettingsToParams($settings, [ + 'nsclient_add_defaults', + 'nsclient_firewall', + 'nsclient_service', + ], $params); + + + $this->addStringSettingsToParams($settings, [ + 'nsclient_directory', + 'nsclient_installer_path' + ], $params); + } + */ + } + + public static function create(Db $db, Settings $settings) + { + return static::load()->setDb($db)->setSettings($settings); + } + + protected function addEventuallyConfiguredBoolean($name, $params) + { + $key = "self-service/$name"; + $value = $this->settings->getStoredValue($key); + $params['value'] = $value; + $params['multiOptions'] = $this->eventuallyConfiguredEnum($name, [ + 'y' => $this->translate('Yes'), + 'n' => $this->translate('No'), + ]); + + return $this->addElement('select', $name, $params); + } + + protected function eventuallyConfiguredEnum($name, $enum) + { + $key = "self-service/$name"; + $default = $this->settings->getDefaultValue($key); + if ($default === null) { + return [ + null => $this->translate('- please choose -') + ] + $enum; + } else { + return [ + null => sprintf($this->translate('%s (default)'), $enum[$default]) + ] + $enum; + } + } + + protected function setSentValue($key, $value) + { + $this->getRequest()->setPost($key, $value); + return $this; + } + + protected function enumGlobalZones() + { + $db = $this->getDb()->getDbAdapter(); + $zones = $db->fetchCol( + $db->select()->from('icinga_zone', 'object_name') + ->where('disabled = ?', 'n') + ->where('is_global = ?', 'y') + ->order('object_name') + ); + + return array_combine($zones, $zones); + } + + public function setSettings(Settings $settings) + { + $this->settings = $settings; + return $this; + } + + public function onSuccess() + { + try { + foreach ($this->getValues() as $key => $value) { + if ($value === '') { + $value = null; + } + + $this->settings->set("self-service/$key", $value); + } + + $this->setSuccessMessage($this->translate( + 'Self Service Settings have been stored' + )); + + parent::onSuccess(); + } catch (Exception $e) { + $this->addException($e); + } + } +} diff --git a/application/forms/SettingsForm.php b/application/forms/SettingsForm.php new file mode 100644 index 0000000..f6ba654 --- /dev/null +++ b/application/forms/SettingsForm.php @@ -0,0 +1,238 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Exception; +use Icinga\Module\Director\Settings; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class SettingsForm extends DirectorForm +{ + /** @var Settings */ + protected $settings; + + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $settings = $this->settings; + + $this->addHtmlHint( + $this->translate( + 'Please only change those settings in case you are really sure' + . ' that you are required to do so. Usually the defaults chosen' + . ' by the Icinga Director should make a good fit for your' + . ' environment.' + ) + ); + $globalZones = $this->eventuallyConfiguredEnum('default_global_zone', $this->enumGlobalZones()); + + $this->addElement('select', 'default_global_zone', array( + 'label' => $this->translate('Default global zone'), + 'multiOptions' => $globalZones, + 'description' => $this->translate( + 'Icinga Director decides to deploy objects like CheckCommands' + . ' to a global zone. This defaults to "director-global" but' + . ' might be adjusted to a custom Zone name' + ), + 'value' => $settings->getStoredValue('default_global_zone') + )); + + $this->addElement('text', 'icinga_package_name', array( + 'label' => $this->translate('Icinga Package Name'), + 'description' => $this->translate( + 'The Icinga Package name Director uses to deploy it\'s configuration.' + . ' This defaults to "director" and should not be changed unless' + . ' you really know what you\'re doing' + ), + 'placeholder' => $settings->get('icinga_package_name'), + 'value' => $settings->getStoredValue('icinga_package_name') + )); + + $this->addElement('select', 'disable_all_jobs', array( + 'label' => $this->translate('Disable all Jobs'), + 'multiOptions' => $this->eventuallyConfiguredEnum( + 'disable_all_jobs', + array( + 'n' => $this->translate('No'), + 'y' => $this->translate('Yes'), + ) + ), + 'description' => $this->translate( + 'Whether all configured Jobs should be disabled' + ), + 'value' => $settings->getStoredValue('disable_all_jobs') + )); + + $this->addElement('select', 'enable_audit_log', array( + 'label' => $this->translate('Enable audit log'), + 'multiOptions' => $this->eventuallyConfiguredEnum( + 'enable_audit_log', + array( + 'n' => $this->translate('No'), + 'y' => $this->translate('Yes'), + ) + ), + 'description' => $this->translate( + 'All changes are tracked in the Director database. In addition' + . ' you might also want to send an audit log through the Icinga' + . " Web 2 logging mechanism. That way all changes would be" + . ' written to either Syslog or the configured log file. When' + . ' enabling this please make sure that you configured Icinga' + . ' Web 2 to log at least at "informational" level.' + ), + 'value' => $settings->getStoredValue('enable_audit_log') + )); + + if ($settings->getStoredValue('ignore_bug7530')) { + // Show this only for those who touched this setting + $this->addElement('select', 'ignore_bug7530', array( + 'label' => $this->translate('Ignore Bug #7530'), + 'multiOptions' => $this->eventuallyConfiguredEnum( + 'ignore_bug7530', + array( + 'n' => $this->translate('No'), + 'y' => $this->translate('Yes'), + ) + ), + 'description' => $this->translate( + 'Icinga v2.11.0 breaks some configurations, the Director will' + . ' warn you before every deployment in case your config is' + . ' affected. This setting allows to hide this warning.' + ), + 'value' => $settings->getStoredValue('ignore_bug7530') + )); + } + + $this->addBoolean('feature_custom_endpoint', [ + 'label' => $this->translate('Feature: Custom Endpoint Name'), + 'description' => $this->translate( + 'Enabled the feature for custom endpoint names,' + . ' where you can choose a different name for the generated endpoint object.' + . ' This uses some Icinga config snippets and a special custom variable.' + . ' Please do NOT enable this, unless you really need divergent endpoint names!' + ), + 'value' => $settings->getStoredValue('feature_custom_endpoint') + ]); + + + $this->addElement('select', 'config_format', array( + 'label' => $this->translate('Configuration format'), + 'multiOptions' => $this->eventuallyConfiguredEnum( + 'config_format', + array( + 'v2' => $this->translate('Icinga v2.x'), + 'v1' => $this->translate('Icinga v1.x'), + ) + ), + 'description' => $this->translate( + 'Default configuration format. Please note that v1.x is for' + . ' special transitional projects only and completely' + . ' unsupported. There are no plans to make Director a first-' + . 'class configuration backends for Icinga 1.x' + ), + 'class' => 'autosubmit', + 'value' => $settings->getStoredValue('config_format') + )); + + $this->setSubmitLabel($this->translate('Store')); + + if ($this->hasBeenSent()) { + if ($this->getSentValue('config_format') !== 'v1') { + return; + } + } elseif ($settings->getStoredValue('config_format') !== 'v1') { + return; + } + + $this->addElement('select', 'deployment_mode_v1', array( + 'label' => $this->translate('Deployment mode'), + 'multiOptions' => $this->eventuallyConfiguredEnum( + 'deployment_mode_v1', + array( + 'active-passive' => $this->translate('Active-Passive'), + 'masterless' => $this->translate('Master-less'), + ) + ), + 'description' => $this->translate( + 'Deployment mode for Icinga 1 configuration' + ), + 'value' => $settings->getStoredValue('deployment_mode_v1') + )); + + $this->addElement('text', 'deployment_path_v1', array( + 'label' => $this->translate('Deployment Path'), + 'description' => $this->translate( + 'Local directory to deploy Icinga 1.x configuration.' + . ' Must be writable by icingaweb2.' + . ' (e.g. /etc/icinga/director)' + ), + 'value' => $settings->getStoredValue('deployment_path_v1') + )); + + $this->addElement('text', 'activation_script_v1', array( + 'label' => $this->translate('Activation Tool'), + 'description' => $this->translate( + 'Script or tool to call when activating a new configuration stage.' + . ' (e.g. sudo /usr/local/bin/icinga-director-activate)' + . ' (name of the stage will be the argument for the script)' + ), + 'value' => $settings->getStoredValue('activation_script_v1') + )); + } + + protected function eventuallyConfiguredEnum($name, $enum) + { + if (array_key_exists($name, $enum)) { + $default = sprintf( + $this->translate('%s (default)'), + $enum[$this->settings->getDefaultValue($name)] + ); + } else { + $default = $this->translate('- please choose -'); + } + + return [null => $default] + $enum; + } + + public function setSettings(Settings $settings) + { + $this->settings = $settings; + return $this; + } + + protected function enumGlobalZones() + { + $db = $this->settings->getDb(); + $zones = $db->fetchCol( + $db->select()->from('icinga_zone', 'object_name') + ->where('disabled = ?', 'n') + ->where('is_global = ?', 'y') + ->order('object_name') + ); + + return array_combine($zones, $zones); + } + + public function onSuccess() + { + try { + foreach ($this->getValues() as $key => $value) { + if ($value === '') { + $value = null; + } + + $this->settings->set($key, $value); + } + + $this->setSuccessMessage($this->translate( + 'Settings have been stored' + )); + + parent::onSuccess(); + } catch (Exception $e) { + $this->addError($e->getMessage()); + } + } +} diff --git a/application/forms/SyncCheckForm.php b/application/forms/SyncCheckForm.php new file mode 100644 index 0000000..8fb3bd0 --- /dev/null +++ b/application/forms/SyncCheckForm.php @@ -0,0 +1,69 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Objects\DirectorActivityLog; +use Icinga\Module\Director\Objects\SyncRule; +use Icinga\Module\Director\Web\Form\DirectorForm; + +class SyncCheckForm extends DirectorForm +{ + /** @var SyncRule */ + protected $rule; + + public function setSyncRule(SyncRule $rule) + { + $this->rule = $rule; + return $this; + } + + public function setup() + { + $this->submitLabel = false; + $this->addElement('submit', 'submit', array( + 'label' => $this->translate('Check for changes'), + 'decorators' => array('ViewHelper') + )); + } + + public function onSuccess() + { + if ($this->rule->checkForChanges()) { + $this->notifySuccess( + $this->translate(('This Sync Rule would apply new changes')) + ); + $sum = [ + DirectorActivityLog::ACTION_CREATE => 0, + DirectorActivityLog::ACTION_MODIFY => 0, + DirectorActivityLog::ACTION_DELETE => 0 + ]; + + // TODO: Preview them? Like "hosta, hostb and 4 more would be... + foreach ($this->rule->getExpectedModifications() as $object) { + if ($object->shouldBeRemoved()) { + $sum[DirectorActivityLog::ACTION_DELETE]++; + } elseif (! $object->hasBeenLoadedFromDb()) { + $sum[DirectorActivityLog::ACTION_CREATE]++; + } elseif ($object->hasBeenModified()) { + $sum[DirectorActivityLog::ACTION_MODIFY]++; + } + } + + /** + if ($sum['modify'] === 1) { + $html .= $this->translate('One object would be modified' + } elseif ($sum['modify'] > 1) { + } + */ + $html = '<pre>' . print_r($sum, 1) . '</pre>'; + + $this->addHtml($html); + } elseif ($this->rule->get('sync_state') === 'in-sync') { + $this->notifySuccess( + $this->translate('Nothing would change, this rule is still in sync') + ); + } else { + $this->addError($this->translate('Checking this sync rule failed')); + } + } +} diff --git a/application/forms/SyncPropertyForm.php b/application/forms/SyncPropertyForm.php new file mode 100644 index 0000000..720237e --- /dev/null +++ b/application/forms/SyncPropertyForm.php @@ -0,0 +1,444 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Exception; +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\Objects\SyncProperty; +use Icinga\Module\Director\Objects\SyncRule; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class SyncPropertyForm extends DirectorObjectForm +{ + /** + * @var SyncRule + */ + private $rule; + + /** @var ImportSource */ + private $importSource; + + /** @var ImportSourceHook */ + private $importSourceHook; + + private $dummyObject; + + const EXPRESSION = '__EXPRESSION__'; + + /** + * @throws \Zend_Form_Exception + */ + public function setup() + { + $this->addHidden('rule_id', $this->rule->get('id')); + + $this->addElement('select', 'source_id', array( + 'label' => $this->translate('Source Name'), + 'multiOptions' => $this->enumImportSource(), + 'required' => true, + 'class' => 'autosubmit', + )); + if (! $this->hasObject() && ! $this->getSentValue('source_id')) { + return; + } + + $this->addElement('select', 'destination_field', array( + 'label' => $this->translate('Destination Field'), + 'multiOptions' => $this->optionalEnum($this->listDestinationFields()), + 'required' => true, + 'class' => 'autosubmit', + )); + + if ($this->getSentValue('destination_field')) { + $destination = $this->getSentValue('destination_field'); + } elseif ($this->hasObject()) { + $destination = $this->getObject()->destination_field; + } else { + return; + } + + $isCustomvar = substr($destination, 0, 5) === 'vars.'; + + if ($isCustomvar) { + $varname = substr($destination, 5); + $this->addElement('text', 'customvar', array( + 'label' => $this->translate('Custom variable'), + 'required' => true, + 'ignore' => true, + )); + + if ($varname !== '*') { + $this->setElementValue('destination_field', 'vars.*'); + $this->setElementValue('customvar', $varname); + if ($this->hasObject()) { + $this->getObject()->destination_field = 'vars.*'; + } + } + } + + $this->addSourceColumnElement($destination); + + $this->addElement('YesNo', 'use_filter', array( + 'label' => $this->translate('Set based on filter'), + 'ignore' => true, + 'class' => 'autosubmit', + 'required' => true, + )); + + if ($this->hasBeenSent()) { + $useFilter = $this->getSentValue('use_filter'); + if ($useFilter === null) { + $this->setElementValue('use_filter', $useFilter = 'n'); + } + } else { + $expression = $this->getObject()->filter_expression; + $useFilter = ($expression === null || strlen($expression) === 0) ? 'n' : 'y'; + $this->setElementValue('use_filter', $useFilter); + } + + if ($useFilter === 'y') { + $this->addElement('text', 'filter_expression', array( + 'label' => $this->translate('Filter Expression'), + 'description' => $this->translate( + 'This allows to filter for specific parts within the given source expression.' + . ' You are allowed to refer all imported columns. Examples: host=www* would' + . ' set this property only for rows imported with a host property starting' + . ' with "www". Complex example: host=www*&!(address=127.*|address6=::1)' + ), + 'required' => true, + // TODO: validate filter + )); + } + + if ($isCustomvar || $destination === 'vars') { + $this->addElement('select', 'merge_policy', array( + 'label' => $this->translate('Merge Policy'), + 'description' => $this->translate( + 'Whether you want to merge or replace the destination field.' + . ' Makes no difference for strings' + ), + 'required' => true, + 'multiOptions' => $this->optionalEnum(array( + 'merge' => 'merge', + 'override' => 'replace' + )) + )); + } else { + $this->addHidden('merge_policy', 'override'); + } + + $this->setButtons(); + } + + protected function hasSubOption($options, $key) + { + foreach ($options as $mainKey => $sub) { + if (! is_array($sub)) { + // null -> please choose - or similar + continue; + } + + if (array_key_exists($key, $sub)) { + return true; + } + } + + return false; + } + + /** + * @param $destination + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addSourceColumnElement($destination) + { + $error = false; + + $srcTitle = $this->translate('Source columns'); + try { + $columns[$srcTitle] = $this->listSourceColumns(); + natsort($columns[$srcTitle]); + } catch (Exception $e) { + $srcTitle .= sprintf(' (%s)', $this->translate('failed to fetch')); + $columns[$srcTitle] = array(); + $error = sprintf( + $this->translate('Unable to fetch data: %s'), + $e->getMessage() + ); + } + + if ($destination === 'import') { + $this->addIcingaTempateColumns($columns); + } elseif ($destination === 'list_id') { + $this->addDatalistsColumns($columns); + } + + $xpTitle = $this->translate('Expert mode'); + $columns[$xpTitle][self::EXPRESSION] = $this->translate('Custom expression'); + + $this->addElement('select', 'source_column', array( + 'label' => $this->translate('Source Column'), + 'multiOptions' => $this->optionalEnum($columns), + 'required' => true, + 'ignore' => true, + 'class' => 'autosubmit', + )); + + if ($error) { + $this->getElement('source_column')->addError($error); + } + + $showExpression = false; + + if ($this->hasBeenSent()) { + $sentValue = $this->getSentValue('source_column'); + if ($sentValue === self::EXPRESSION) { + $showExpression = true; + } + } elseif ($this->hasObject()) { + $objectValue = $this->getObject()->source_expression; + if ($this->hasSubOption($columns, $objectValue)) { + $this->setElementValue('source_column', $objectValue); + } else { + $this->setElementValue('source_column', self::EXPRESSION); + $showExpression = true; + } + } + + if ($showExpression) { + $this->addElement('text', 'source_expression', array( + 'label' => $this->translate('Source Expression'), + 'description' => $this->translate( + 'A custom string. Might contain source columns, please use placeholders' + . ' of the form ${columnName} in such case. Structured data sources' + . ' can be referenced as ${columnName.sub.key}' + ), + 'required' => true, + )); + } + + + return $this; + } + + protected function addIcingaTempateColumns(&$columns) + { + $funcTemplates = 'enum' . ucfirst($this->rule->get('object_type')) . 'Templates'; + if (method_exists($this->db, $funcTemplates)) { + $templates = $this->db->$funcTemplates(); + if (! empty($templates)) { + $templates = array_combine($templates, $templates); + } + + $title = $this->translate('Existing templates'); + $columns[$title] = $templates; + natsort($columns[$title]); + } + } + + protected function addDatalistsColumns(&$columns) + { + // Clear other columns, we don't allow them right now + $columns = []; + $db = $this->db->getDbAdapter(); + $enum = $db->fetchPairs( + $db->select()->from('director_datalist', ['id', 'list_name'])->order('list_name') + ); + + $columns[$this->translate('Existing Data Lists')] = $enum; + } + + protected function enumImportSource() + { + $sources = $this->db->enumImportSource(); + $usedIds = $this->rule->listInvolvedSourceIds(); + if (empty($usedIds)) { + return $this->optionalEnum($sources); + } + $usedSources = array(); + foreach ($usedIds as $id) { + $usedSources[$id] = $sources[$id]; + unset($sources[$id]); + } + + if (empty($sources)) { + return $this->optionalEnum($usedSources); + } + + return $this->optionalEnum( + array( + $this->translate('Used sources') => $usedSources, + $this->translate('Other sources') => $sources + ) + ); + } + + /** + * @return array + * @throws \Icinga\Exception\ConfigurationError + * @throws \Icinga\Exception\NotFoundError + */ + protected function listSourceColumns() + { + $columns = array(); + $source = $this->getImportSource(); + $hook = $this->getImportSourceHook(); + foreach ($hook->listColumns() as $col) { + $columns['${' . $col . '}'] = $col; + } + + foreach ($source->listModifierTargetProperties() as $property) { + $columns['${' . $property . '}'] = $property; + } + + return $columns; + } + + protected function listDestinationFields() + { + $props = []; + $special = []; + $dummy = $this->dummyObject(); + + if ($dummy instanceof IcingaObject) { + if ($dummy->supportsCustomVars()) { + $special['vars.*'] = $this->translate('Custom variable (vars.)'); + $special['vars'] = $this->translate('All custom variables (vars)'); + } + if ($dummy->supportsImports()) { + $special['import'] = $this->translate('Inheritance (import)'); + } + if ($dummy->supportsArguments()) { + $special['arguments'] = $this->translate('Arguments'); + } + if ($dummy->supportsGroups()) { + $special['groups'] = $this->translate('Group membership'); + } + if ($dummy->supportsRanges()) { + $special['ranges'] = $this->translate('Time ranges'); + } + } + + foreach ($dummy->listProperties() as $prop) { + if ($dummy instanceof IcingaObject && $prop === 'id') { + continue; + } + + // TODO: allow those fields, but munge them (store ids) + //if (preg_match('~_id$~', $prop)) continue; + if (substr($prop, -3) === '_id') { + $short = substr($prop, 0, -3); + if ($dummy instanceof IcingaObject) { + if ($dummy->hasRelation($short)) { + $prop = $short; + } else { + continue; + } + } + } + + $props[$prop] = $prop; + } + + if ($dummy instanceof IcingaObject) { + foreach ($dummy->listMultiRelations() as $prop) { + $props[$prop] = sprintf('%s (%s)', $prop, $this->translate('a list')); + } + } + + ksort($props); + + $result = []; + if (! empty($special)) { + $result[$this->translate('Special properties')] = $special; + } + if (! empty($props)) { + $result[$this->translate('Object properties')] = $props; + } + + return $result; + } + + /** + * @return ImportSource + * @throws \Icinga\Exception\NotFoundError + */ + protected function getImportSource() + { + if ($this->importSource === null) { + if ($this->hasObject()) { + $id = (int) $this->object->get('source_id'); + } else { + $id = (int) $this->getSentValue('source_id'); + } + $this->importSource = ImportSource::loadWithAutoIncId($id, $this->db); + } + + return $this->importSource; + } + + /** + * @return ImportSourceHook + * @throws \Icinga\Exception\ConfigurationError + * @throws \Icinga\Exception\NotFoundError + */ + protected function getImportSourceHook() + { + if ($this->importSourceHook === null) { + $this->importSourceHook = ImportSourceHook::loadByName( + $this->getImportSource()->get('source_name'), + $this->db + ); + } + + return $this->importSourceHook; + } + + public function onSuccess() + { + /** @var SyncProperty $object */ + $object = $this->getObject(); + $object->set('rule_id', $this->rule->get('id')); // ?! + + if ($this->getValue('use_filter') === 'n') { + $object->set('filter_expression', null); + } + + $sourceColumn = $this->getValue('source_column'); + $this->removeElement('source_column'); + + if ($sourceColumn !== self::EXPRESSION) { + $object->set('source_expression', $sourceColumn); + } + + $destination = $this->getValue('destination_field'); + if ($destination === 'vars.*') { + $destination = $this->getValue('customvar'); + $object->set('destination_field', 'vars.' . $destination); + } + + return parent::onSuccess(); + } + + protected function dummyObject() + { + if ($this->dummyObject === null) { + $this->dummyObject = IcingaObject::createByType( + $this->rule->get('object_type'), + array(), + $this->db + ); + } + + return $this->dummyObject; + } + + public function setRule(SyncRule $rule) + { + $this->rule = $rule; + return $this; + } +} diff --git a/application/forms/SyncRuleForm.php b/application/forms/SyncRuleForm.php new file mode 100644 index 0000000..d88e493 --- /dev/null +++ b/application/forms/SyncRuleForm.php @@ -0,0 +1,112 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class SyncRuleForm extends DirectorObjectForm +{ + public function setup() + { + $availableTypes = [ + 'host' => $this->translate('Host'), + 'hostgroup' => $this->translate('Host Group'), + 'service' => $this->translate('Service'), + 'servicegroup' => $this->translate('Service Group'), + 'serviceSet' => $this->translate('Service Set'), + 'user' => $this->translate('User'), + 'usergroup' => $this->translate('User Group'), + 'datalistEntry' => $this->translate('Data List Entry'), + 'command' => $this->translate('Command'), + 'timePeriod' => $this->translate('Time Period'), + 'notification' => $this->translate('Notification'), + 'scheduledDowntime' => $this->translate('Scheduled Downtime'), + 'dependency' => $this->translate('Dependency'), + 'endpoint' => $this->translate('Endpoint'), + 'zone' => $this->translate('Zone'), + ]; + + $this->addElement('text', 'rule_name', [ + 'label' => $this->translate('Rule name'), + 'description' => $this->translate('Please provide a rule name'), + 'required' => true, + ]); + + $this->addElement('textarea', 'description', [ + 'label' => $this->translate('Description'), + 'description' => $this->translate( + 'An extended description for this Sync Rule. This should explain' + . ' what this Rule is going to accomplish.' + ), + 'rows' => '3', + ]); + + $this->addElement('select', 'object_type', [ + 'label' => $this->translate('Object Type'), + 'description' => $this->translate('Choose an object type'), + 'required' => true, + 'multiOptions' => $this->optionalEnum($availableTypes) + ]); + + $this->addElement('select', 'update_policy', [ + 'label' => $this->translate('Update Policy'), + 'description' => $this->translate( + 'Define what should happen when an object with a matching key' + . " already exists. You could merge its properties (import source" + . ' wins), replace it completely with the imported object or ignore' + . ' it (helpful for one-time imports). "Update only" means that this' + . ' Rule would never create (or delete) full Objects.' + ), + 'required' => true, + 'multiOptions' => $this->optionalEnum([ + 'merge' => $this->translate('Merge'), + 'override' => $this->translate('Replace'), + 'ignore' => $this->translate('Ignore'), + 'update-only' => $this->translate('Update only'), + ]) + ]); + + $this->addBoolean('purge_existing', [ + 'label' => $this->translate('Purge'), + 'description' => $this->translate( + 'Whether to purge existing objects. This means that objects of' + . ' the same type will be removed from Director in case they no' + . ' longer exist at your import source.' + ), + 'required' => true, + 'class' => 'autosubmit', + ]); + + if ($this->getSentOrObjectValue('purge_existing') === 'y') { + $this->addElement('select', 'purge_action', [ + 'label' => $this->translate('Purge Action'), + 'description' => $this->translate( + 'Whether to delete or to disable objects subject to purge' + ), + 'multiOptions' => $this->optionalEnum([ + 'delete' => $this->translate('Delete'), + 'disable' => $this->translate('Disable'), + ]), + 'required' => true, + ]); + } + + $this->addElement('text', 'filter_expression', [ + 'label' => $this->translate('Filter Expression'), + 'description' => sprintf( + $this->translate( + 'Sync only part of your imported objects with this rule. Icinga Web 2' + . ' filter syntax is allowed, so this could look as follows: %s' + ), + '(host=a|host=b)&!ip=127.*' + ) . ' ' . $this->translate( + 'Be careful: this is usually NOT what you want, as it makes Sync "blind"' + . ' for objects matching this filter. This means that "Purge" will not' + . ' work as expected. The "Black/Whitelist" Import Property Modifier' + . ' is probably what you\'re looking for.' + ), + ]); + + $this->setButtons(); + } +} diff --git a/application/forms/SyncRunForm.php b/application/forms/SyncRunForm.php new file mode 100644 index 0000000..0bc5fda --- /dev/null +++ b/application/forms/SyncRunForm.php @@ -0,0 +1,67 @@ +<?php + +namespace Icinga\Module\Director\Forms; + +use gipfl\Translation\TranslationHelper; +use gipfl\Web\Form; +use Icinga\Module\Director\Data\Db\DbObjectStore; +use Icinga\Module\Director\Import\Sync; +use Icinga\Module\Director\Objects\SyncRule; + +class SyncRunForm extends Form +{ + use TranslationHelper; + + protected $defaultDecoratorClass = null; + + /** @var ?string */ + protected $successMessage = null; + + /** @var SyncRule */ + protected $rule; + + /** @var DbObjectStore */ + protected $store; + + public function __construct(SyncRule $rule, DbObjectStore $store) + { + $this->rule = $rule; + $this->store = $store; + } + + public function assemble() + { + if ($this->store->getBranch()->isBranch()) { + $label = sprintf($this->translate('Sync to Branch: %s'), $this->store->getBranch()->getName()); + } else { + $label = $this->translate('Trigger this Sync'); + } + $this->addElement('submit', 'submit', [ + 'label' => $label, + ]); + } + + /** + * @return string|null + */ + public function getSuccessMessage() + { + return $this->successMessage; + } + + public function onSuccess() + { + $sync = new Sync($this->rule, $this->store); + if ($sync->hasModifications()) { + if ($sync->apply()) { + // and changed + $this->successMessage = $this->translate(('Source has successfully been synchronized')); + } else { + $this->successMessage = $this->translate('Nothing changed, rule is in sync'); + } + } else { + // Used to be $rule->get('sync_state') === 'in-sync', $changed = $rule->applyChanges(); + $this->successMessage = $this->translate('Nothing to do, rule is in sync'); + } + } +} |