diff --git a/application/clicommands/ExportCommand.php b/application/clicommands/ExportCommand.php index 2b2119d7a..337e05b5d 100644 --- a/application/clicommands/ExportCommand.php +++ b/application/clicommands/ExportCommand.php @@ -94,6 +94,27 @@ public function datafieldsAction() ); } + /** + * Export all CustomProperty definitions + * + * USAGE + * + * icingacli director export customproperties [options] + * + * OPTIONS + * + * --no-pretty JSON is pretty-printed per default + * Use this flag to enforce unformatted JSON + */ + public function custompropertiesAction() + { + $export = new ImportExport($this->db()); + echo $this->renderJson( + $export->serializeAllCustomProperties(), + ! $this->params->shift('no-pretty') + ); + } + /** * Export all DataList definitions * diff --git a/application/clicommands/HostsCommand.php b/application/clicommands/HostsCommand.php index 3008284da..a34785c4e 100644 --- a/application/clicommands/HostsCommand.php +++ b/application/clicommands/HostsCommand.php @@ -2,7 +2,14 @@ namespace Icinga\Module\Director\Clicommands; +use GuzzleHttp\Psr7\ServerRequest; use Icinga\Module\Director\Cli\ObjectsCommand; +use Icinga\Module\Director\Forms\CustomPropertiesForm; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use PDO; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; /** * Manage Icinga Hosts @@ -11,4 +18,71 @@ */ class HostsCommand extends ObjectsCommand { + public function refreshCustomVarsAction(): void + { + foreach ($this->getObjects() as $o) { + $vars = $o->vars(); + $objectProperties = $this->getObjectCustomProperties($o); + + foreach ($objectProperties as $key => $property) { + $var = $vars->get($key); + if ($var && $property['uuid'] !== null) { + $var->setUuid(Uuid::fromBytes($property['uuid'])); + $vars->set($key, $var); + } + } + + $vars->storeToDb($o); + } + } + + private function getObjectCustomProperties(IcingaObject $object) + { + if ($object->uuid === null) { + return []; + } + + $type = $object->getShortTableName(); + + $parents = $object->listAncestorIds(); + + $uuids = []; + $db = $object->getConnection(); + + foreach ($parents as $parent) { + $uuids[] = IcingaHost::loadWithAutoIncId($parent, $db)->get('uuid'); + } + + $uuids[] = (int) $object->get('uuid'); + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + $type . '_uuid' => 'iop.' . $type . '_uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label', + 'children' => 'COUNT(cdp.uuid)' + ] + ) + ->join(['iop' => "icinga_$type" . '_property'], 'dp.uuid = iop.property_uuid', []) + ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) + ->where('iop.' . $type . '_uuid IN (?)', $uuids) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->order( + "FIELD(dp.value_type, 'string', 'number', 'bool', 'fixed-array'," + . " 'dynamic-array', 'fixed-dictionary', 'dynamic-dictionary')" + ) + ->order('children') + ->order('key_name'); + + $result = []; + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + $result[$row['key_name']] = $row; + } + + return $result; + } } diff --git a/application/controllers/BasketController.php b/application/controllers/BasketController.php index 8d4db034e..baaa14270 100644 --- a/application/controllers/BasketController.php +++ b/application/controllers/BasketController.php @@ -272,7 +272,7 @@ public function snapshotAction() $this->addSingleTab($this->translate('Snapshot')); $diff = new BasketDiff($snapshot, $connection); foreach ($diff->getBasketObjects() as $type => $objects) { - if ($type === 'Datafield') { + if ($type === 'Datafield' || $type === 'Property') { // TODO: we should now be able to show all fields and link // to a "diff" for the ones that should be created // $this->content()->add(Html::tag('h2', sprintf('+%d Datafield(s)', count($objects)))); diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php index 5edfbcfab..f299cd6c1 100644 --- a/application/controllers/HostController.php +++ b/application/controllers/HostController.php @@ -4,6 +4,8 @@ use gipfl\Web\Widget\Hint; use Icinga\Module\Director\Auth\Permission; +use Icinga\Module\Director\Forms\CustomPropertiesForm; +use Icinga\Module\Director\Forms\DictionaryElements\Dictionary; use Icinga\Module\Director\Integration\Icingadb\IcingadbBackend; use Icinga\Module\Director\Integration\MonitoringModule\Monitoring; use Icinga\Module\Director\Web\Table\ObjectsTableService; @@ -12,7 +14,6 @@ use gipfl\IcingaWeb2\Url; use gipfl\IcingaWeb2\Widget\Tabs; use Exception; -use Icinga\Module\Director\CustomVariable\CustomVariableDictionary; use Icinga\Module\Director\Db\AppliedServiceSetLoader; use Icinga\Module\Director\DirectorObject\Lookup\ServiceFinder; use Icinga\Module\Director\Forms\IcingaAddServiceForm; @@ -25,9 +26,10 @@ use Icinga\Module\Director\Repository\IcingaTemplateRepository; use Icinga\Module\Director\Web\Controller\ObjectController; use Icinga\Module\Director\Web\SelfService; -use Icinga\Module\Director\Web\Table\IcingaHostAppliedForServiceTable; use Icinga\Module\Director\Web\Table\IcingaHostAppliedServicesTable; use Icinga\Module\Director\Web\Table\IcingaServiceSetServiceTable; +use ipl\Web\Widget\ButtonLink; +use PDO; class HostController extends ObjectController { @@ -89,6 +91,126 @@ public function editAction() $this->addOptionalMonitoringLink(); } + public function variablesAction(): void + { + $this->assertPermission('director/admin'); + $object = $this->requireObject(); + + $this->addTitle( + $this->translate('Custom Variables: %s'), + $object->getObjectName() + ); + + $objectProperties = $this->getObjectCustomProperties(); + if ($this->object->isTemplate()) { + $this->actions()->add( + (new ButtonLink( + $this->translate('Add Property'), + Url::fromPath('director/host/add-property', ['uuid' => $this->getUuidFromUrl()])->getAbsoluteUrl(), + null, + ['class' => 'control-button'] + ))->openInModal() + ); + } + + if ($objectProperties) { + $vars = json_decode(json_encode($this->object->getVars()), true); + $inheritedVars = json_decode(json_encode($this->object->getInheritedVars()), JSON_OBJECT_AS_ARRAY); + $origins = $this->object->getOriginsVars(); + + $form = (new CustomPropertiesForm($object, $objectProperties)) + ->on(CustomPropertiesForm::ON_SUCCESS, function () { + $this->redirectNow(Url::fromRequest()); + }) + ->on(CustomPropertiesForm::ON_SENT, function (CustomPropertiesForm $form) use (&$vars) { + /** @var Dictionary $propertiesElement */ + $propertiesElement = $form->getElement('properties'); + $vars = $propertiesElement->getDictionary(); + }) + ->handleRequest($this->getServerRequest()); + + $result = []; + foreach ($objectProperties as $row) { + if (isset($vars[$row['key_name']])) { + $row['value'] = $vars[$row['key_name']]; + } + + if (isset($inheritedVars[$row['key_name']])) { + $row['inherited'] = $inheritedVars[$row['key_name']]; + $row['inherited_from'] = $origins->{$row['key_name']}; + } + + $result[] = $row; + } + + $form->load($result); + $this->content()->add($form); + } + + $this->tabs()->activate('variables'); + } + + /** + * Get custom properties for the host. + * + * @return array + */ + protected function getObjectCustomProperties(): array + { + if ($this->object->uuid === null) { + return []; + } + + $type = $this->object->getShortTableName(); + + $parents = $this->object->listAncestorIds(); + + $uuids = []; + $db = $this->db(); + foreach ($parents as $parent) { + $uuids[] = IcingaHost::load($parent, $db)->get('uuid'); + } + + $objectUuid = $this->object->get('uuid'); + $uuids[] = $this->object->get('uuid'); + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + $type . '_uuid' => 'iop.' . $type . '_uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label', + 'children' => 'COUNT(cdp.uuid)' + ] + ) + ->join(['iop' => "icinga_$type" . '_property'], 'dp.uuid = iop.property_uuid', []) + ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) + ->where('iop.' . $type . '_uuid IN (?)', $uuids) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->order( + "FIELD(dp.value_type, 'string', 'number', 'bool', 'fixed-array'," + . " 'dynamic-array', 'fixed-dictionary', 'dynamic-dictionary')" + ) + ->order('children') + ->order('key_name'); + + $result = []; + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + if ($objectUuid === $row[$type . '_uuid']) { + $row['allow_removal'] = true; + } else { + $row['allow_removal'] = false; + } + + $result[$row['key_name']] = $row; + } + + return $result; + } + public function serviceAction() { $host = $this->getHostObject(); @@ -99,7 +221,7 @@ public function serviceAction() ->setBranch($this->getBranch()) ->setHost($host) ->setDb($this->db()) - ->handleRequest() + ->handleRequest(), ); } @@ -114,7 +236,7 @@ public function servicesetAction() ->setBranch($this->getBranch()) ->setHost($host) ->setDb($this->db()) - ->handleRequest() + ->handleRequest(), ); } @@ -128,12 +250,12 @@ protected function addServicesHeader() $this->translate('Add service'), 'director/host/service', ['name' => $hostname], - ['class' => 'icon-plus'] + ['class' => 'icon-plus'], ))->add(Link::create( $this->translate('Add service set'), 'director/host/serviceset', ['name' => $hostname], - ['class' => 'icon-plus'] + ['class' => 'icon-plus'], )); } @@ -159,7 +281,7 @@ public function findserviceAction() } elseif ($auth->hasPermission($this->getServicesReadOnlyPermission())) { $redirectUrl = Url::fromPath('director/host/servicesro', [ 'name' => $hostName, - 'service' => $serviceName + 'service' => $serviceName, ]); } else { $redirectUrl = Url::fromPath('director/host/invalidservice', [ @@ -179,7 +301,7 @@ public function invalidserviceAction() if (! $this->showInfoForNonDirectorService()) { $this->content()->add(Hint::error(sprintf( $this->translate('No such service: %s'), - $this->params->get('service') + $this->params->get('service'), ))); } @@ -199,7 +321,7 @@ protected function showInfoForNonDirectorService() 'The configuration for this object has not been rendered by' . ' Icinga Director. You can find it on line %s in %s.', Html::tag('strong', null, $source->first_line), - Html::tag('strong', null, $source->path) + Html::tag('strong', null, $source->path), ))); } } @@ -245,8 +367,8 @@ public function servicesAction() $content->add( $table->setTitle(sprintf( $this->translate('Inherited from %s'), - $parent->getObjectName() - )) + $parent->getObjectName(), + )), ); } } @@ -268,7 +390,7 @@ public function servicesAction() ->setBranch($branch) ->setAffectedHost($host) ->setTitle($title) - ->removeQueryLimit() + ->removeQueryLimit(), ); } @@ -326,8 +448,8 @@ public function servicesroAction() $content->add( $table->setTitle(sprintf( 'Inherited from %s', - $parent->getObjectName() - )) + $parent->getObjectName(), + )), ); } } @@ -348,7 +470,7 @@ public function servicesroAction() ->setAffectedHost($host) ->setReadonly() ->highlightService($service) - ->setTitle($title) + ->setTitle($title), ); } @@ -379,15 +501,15 @@ protected function addHostServiceSetTables(IcingaHost $host, IcingaHost $affecte $query = $db->getDbAdapter()->select() ->from( array('ss' => 'icinga_service_set'), - 'ss.*' + 'ss.*', )->join( array('hsi' => 'icinga_service_set_inheritance'), 'hsi.parent_service_set_id = ss.id', - array() + array(), )->join( array('hs' => 'icinga_service_set'), 'hs.id = hsi.service_set_id', - array() + array(), )->where('hs.host_id = ?', $host->get('id')); $sets = IcingaServiceSet::loadAll($db, $query, 'object_name'); @@ -428,7 +550,7 @@ public function appliedserviceAction() $this->addTitle( $this->translate('Applied service: %s'), - $serviceName + $serviceName, ); $this->content()->add( @@ -438,7 +560,7 @@ public function appliedserviceAction() ->setHost($host) ->setApplyGenerated($parent) ->setObject($service) - ->handleRequest() + ->handleRequest(), ); $this->commonForServices(); @@ -456,7 +578,7 @@ public function inheritedserviceAction() $parent = IcingaService::load([ 'object_name' => $serviceName, - 'host_id' => $from->get('id') + 'host_id' => $from->get('id'), ], $this->db()); // TODO: we want to eventually show the host template name, doesn't work @@ -493,21 +615,21 @@ public function removesetAction() $db = $this->db()->getDbAdapter(); $query = $db->select()->from( array('ss' => 'icinga_service_set'), - array('id' => 'ss.id') + array('id' => 'ss.id'), )->join( array('si' => 'icinga_service_set_inheritance'), 'si.service_set_id = ss.id', - array() + array(), )->where( 'si.parent_service_set_id = ?', - $this->params->get('setId') + $this->params->get('setId'), )->where('ss.host_id = ?', $this->object->get('id')); IcingaServiceSet::loadWithAutoIncId($db->fetchOne($query), $this->db())->delete(); $this->redirectNow( Url::fromPath('director/host/services', array( - 'name' => $this->object->getObjectName() - )) + 'name' => $this->object->getObjectName(), + )), ); } @@ -521,7 +643,7 @@ public function servicesetserviceAction() $serviceName = $this->params->get('service'); $setParams = [ 'object_name' => $this->params->get('set'), - 'host_id' => $host->get('id') + 'host_id' => $host->get('id'), ]; $setTemplate = IcingaServiceSet::load($this->params->get('set'), $db); if (IcingaServiceSet::exists($setParams, $db)) { @@ -532,7 +654,7 @@ public function servicesetserviceAction() $service = IcingaService::load([ 'object_name' => $serviceName, - 'service_set_id' => $setTemplate->get('id') + 'service_set_id' => $setTemplate->get('id'), ], $this->db()); $service = IcingaService::create([ 'id' => $service->get('id'), @@ -548,7 +670,7 @@ public function servicesetserviceAction() $this->translate('%s on %s (from set: %s)'), $serviceName, $host->getObjectName(), - $set->getObjectName() + $set->getObjectName(), ); $form = IcingaServiceForm::load() @@ -570,7 +692,7 @@ protected function commonForServices() $this->translate('back'), 'director/host/services', ['name' => $host->getObjectName()], - ['class' => 'icon-left-big'] + ['class' => 'icon-left-big'], )); $this->tabs()->activate('services'); } @@ -605,8 +727,8 @@ protected function addOptionalMonitoringLink() $this->translate('Show'), $backend->getHostUrl($host->getObjectName()), null, - ['class' => 'icon-globe critical', 'data-base-target' => '_next'] - ) + ['class' => 'icon-globe critical', 'data-base-target' => '_next'], + ), ); // Intentionally placed here, show it only for deployed Hosts @@ -629,12 +751,12 @@ protected function addOptionalInspectLink() [ 'type' => 'host', 'plural' => 'hosts', - 'name' => $this->object->getObjectName() + 'name' => $this->object->getObjectName(), ], [ 'class' => 'icon-zoom-in', - 'data-base-target' => '_next' - ] + 'data-base-target' => '_next', + ], )); } diff --git a/application/controllers/PropertiesController.php b/application/controllers/PropertiesController.php new file mode 100644 index 000000000..5902ab0e5 --- /dev/null +++ b/application/controllers/PropertiesController.php @@ -0,0 +1,78 @@ +addTitleTab($this->translate('Properties')); + + $db = Db::fromResourceName( + Config::module('director')->get('db', 'resource') + )->getDbAdapter(); + + $query = $db->select() + ->from(['dp' => 'director_property'], []) + ->joinLeft(['ihp' => 'icinga_host_property'], 'ihp.property_uuid = dp.uuid', []) + ->columns([ + 'key_name', + 'uuid', + 'parent_uuid', + 'value_type', + 'label', + 'description', + 'used_count' => 'COUNT(ihp.property_uuid)' + ]) + ->where('parent_uuid IS NULL') + ->group('dp.uuid') + ->order('key_name'); + + $properties = new PropertyTable($db->fetchAll($query)); + + $this->addControl(Html::tag('div', ['class' => 'property-form'], [ + (new ButtonLink( + [Text::create('Create property')], + Url::fromPath('director/properties/add'), + null, + [ + 'class' => 'control-button' + ] + ))->setBaseTarget('_next') + ])); + + $this->addContent($properties); + } + + public function addAction() + { + $this->addTitleTab($this->translate('Add property')); + $db = Db::fromResourceName( + Config::module('director')->get('db', 'resource') + ); + + $propertyForm = (new PropertyForm($db)) + ->on(PropertyForm::ON_SUCCESS, function (PropertyForm $form) { + Notification::success(sprintf( + $this->translate('Property "%s" has successfully been added'), + $form->getValue('key_name') + )); + + $this->redirectNow(Url::fromPath('director/property', ['uuid' => $form->getUUid()->toString()])); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($propertyForm); + } +} diff --git a/application/controllers/PropertyController.php b/application/controllers/PropertyController.php new file mode 100644 index 000000000..645f2d941 --- /dev/null +++ b/application/controllers/PropertyController.php @@ -0,0 +1,353 @@ +uuid = Uuid::fromString($this->params->shiftRequired('uuid')); + $parentUuid = $this->params->shift('parent_uuid'); + + if ($parentUuid) { + $this->parentUuid = Uuid::fromString($parentUuid); + } + + $this->db = Db::fromResourceName( + Config::module('director')->get('db', 'resource') + ); + } + + public function indexAction() + { + $uuid = $this->uuid; + $parentUuid = $this->parentUuid ?? null; + $parent = []; + $db = $this->db->getDbAdapter(); + $property = $this->fetchProperty($uuid); + + if ($parentUuid) { + $parentUuid = Uuid::fromString($parentUuid); + $parent = $this->fetchProperty($parentUuid); + + if ($parent['parent_uuid'] !== null) { + $usedCount = $this->fetchPropertyUsedCount(Uuid::fromBytes($parent['parent_uuid'])); + } else { + $usedCount = $this->fetchPropertyUsedCount($parentUuid); + } + } else { + $usedCount = $this->fetchPropertyUsedCount($uuid); + } + + $property['used_count'] = $usedCount; + + if ($property['value_type'] === 'dynamic-array') { + $itemTypeQuery = $db + ->select()->from('director_property', 'value_type') + ->where( + 'parent_uuid = ? AND key_name = \'0\'', + $uuid->getBytes() + ); + + $property['item_type'] = $db->fetchOne($itemTypeQuery); + } + + $showFields = $this->showFields($property['value_type']); + $propertyForm = (new PropertyForm($this->db, $uuid, $parentUuid !== null, $parentUuid)) + ->populate($property) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(PropertyForm::ON_SENT, function (PropertyForm $form) use ($property, &$showFields) { + $showFields = $showFields && $form->getValue('value_type') === $property['value_type']; + }) + ->on(PropertyForm::ON_SUCCESS, function (PropertyForm $form) { + Notification::success(sprintf( + $this->translate('Property "%s" has successfully been saved'), + $form->getValue('key_name') + )); + + $this->sendExtraUpdates(['#col1']); + $redirectUrl = Url::fromPath( + 'director/property', + ['uuid' => $form->getUUid()->toString()] + ); + + if ($form->getParentUUid()) { + $redirectUrl->addParams(['parent_uuid' => $form->getParentUUid()->toString()]); + } + + $this->redirectNow($redirectUrl); + }); + + if ($parent) { + $propertyForm + ->setHideKeyNameElement($parent['value_type'] === 'fixed-array') + ->setIsNestedField($parent['parent_uuid'] !== null); + } + + $propertyForm->handleRequest($this->getServerRequest()); + $this->addContent($propertyForm); + + if ($showFields) { + $this->addContent(new HtmlElement('h2', null, Text::create($this->translate('Fields')))); + $button = (new ButtonLink( + Text::create($this->translate('Add Field')), + Url::fromPath('director/property/add-field', [ + 'uuid' => $uuid->toString() + ]), + null, + ['class' => 'control-button'] + ))->openInModal(); + + $fieldQuery = $db + ->select() + ->from(['dp' => 'director_property'], []) + ->joinLeft(['ihp' => 'icinga_host_property'], 'ihp.property_uuid = dp.parent_uuid', []) + ->columns([ + 'uuid', + 'parent_uuid', + 'key_name', + 'value_type', + 'label', + 'description', + 'used_count' => $property['used_count'] > 0 ? 'COUNT(1)' : '0', + ]) + ->where('parent_uuid = ?', $uuid->getBytes()) + ->group('dp.uuid') + ->order('key_name'); + + $this->addContent($button); + + $fields = $db->fetchAll($fieldQuery); + + if (empty($fields)) { + $this->addContent( + new EmptyStateBar( + $this->translate('No fields have been added yet') + ) + ); + } else { + $this->addContent(new PropertyTable($fields, true)); + } + } + + if ($parentUuid) { + $keyName = $parent['value_type'] === 'fixed-array' + ? $property['label'] + : $property['key_name']; + + $title = $this->translate('Edit Field') . ': ' . $keyName; + } else { + $title = $this->translate('Property') . ': ' . $property['key_name']; + } + + $this->setTitle($title); + $this->setTitleTab('property'); + } + + public function usageAction(): void + { + $uuid = $this->uuid; + $property = $this->fetchProperty($uuid); + if (isset($property['parent_uuid'])) { + $parentUuid = Uuid::fromBytes($property['parent_uuid']); + $this->parentUuid = $parentUuid; + $parentProperty = $this->fetchProperty($parentUuid); + if (isset($parentProperty['parent_uuid'])) { + $rootUuid = Uuid::fromBytes($parentProperty['parent_uuid']); + } else { + $rootUuid = $parentUuid; + } + + $uuid = $rootUuid; + } + + $db = $this->db->getDbAdapter(); + + $customPropQuery = $db + ->select() + ->from(['ih' => 'icinga_host'], []) + ->join(['ihv' => 'icinga_host_var'], 'ih.id = ihv.host_id', []) + ->join(['dp' => 'director_property'], 'ihv.property_uuid = dp.uuid', []) + ->columns([ + 'name' => 'ih.object_name', + 'type' => 'ih.object_type' + ]) + ->where('dp.uuid = ?', $uuid->getBytes()); + + $unionQuery = $db + ->select() + ->from(['ih' => 'icinga_host'], []) + ->join(['ihp' => 'icinga_host_property'], 'ihp.host_uuid = ih.uuid', []) + ->join(['dp' => 'director_property'], 'ihp.property_uuid = dp.uuid', []) + ->columns([ + 'name' => 'ih.object_name', + 'type' => 'ih.object_type' + ]) + ->where('ihp.property_uuid = ?', $uuid->getBytes()); + $this->addContent( + (new CustomVarObjectList($db->fetchAll($db->select()->union([$customPropQuery, $unionQuery])))) + ->setDetailActionsDisabled(false) + ); + + $this->setTitle($this->translate('Custom Variable Usage')); + $this->setTitleTab('usage'); + } + + private function showFields(string $type): bool + { + return in_array($type, ['fixed-array', 'fixed-dictionary', 'dynamic-dictionary'], true); + } + + public function addFieldAction() + { + $uuid = $this->uuid; + $this->addTitleTab($this->translate('Add Field')); + $uuid = Uuid::fromString($uuid); + + $parent = $this->fetchProperty($uuid); + $propertyForm = (new PropertyForm($this->db, null, true, $uuid)) + ->setHideKeyNameElement($parent['value_type'] === 'fixed-array') + ->setIsNestedField($parent['parent_uuid'] !== null) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(PropertyForm::ON_SUCCESS, function (PropertyForm $form) { + Notification::success(sprintf( + $this->translate('Property "%s" has successfully been saved'), + $form->getValue('key_name') + )); + + $this->sendExtraUpdates(['#col1']); + $this->redirectNow( + Url::fromPath('director/property', ['uuid' => $form->getParentUUid()->toString()]) + ); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($propertyForm); + } + + public function deleteAction(): void + { + $uuid = $this->uuid; + $property = $this->fetchProperty($uuid); + $parent = []; + if ($property['parent_uuid'] !== null) { + $parent = $this->fetchProperty(Uuid::fromBytes($property['parent_uuid'])); + } + + $form = (new DeletePropertyForm($this->db, $property, $parent)) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(DeletePropertyForm::ON_SUCCESS, function () { + Notification::success($this->translate('Property has successfully been deleted')); + $this->sendExtraUpdates(['#col1']); + $this->redirectNow('__CLOSE__'); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($form); + + $this->setTitle($this->translate('Delete Property') . ': ' . $property['key_name']); + } + + /** + * Fetch property for the given UUID + * + * @param UuidInterface $uuid UUID of the given property + * + * @return array + */ + private function fetchProperty(UuidInterface $uuid): array + { + $db = $this->db->getDbAdapter(); + + $query = $db + ->select() + ->from(['dp' => 'director_property'], []) + ->joinLeft(['ihp' => 'icinga_host_property'], 'ihp.property_uuid = dp.uuid', []) + ->columns([ + 'key_name', + 'uuid', + 'parent_uuid', + 'value_type', + 'label', + 'description' + ]) + ->where('uuid = ?', $uuid->getBytes()); + + return $db->fetchRow($query, [], Zend_Db::FETCH_ASSOC); + } + + private function fetchPropertyUsedCount(UuidInterface $uuid): int + { + $db = $this->db->getDbAdapter(); + + $query = $db + ->select() + ->from(['dp' => 'director_property'], []) + ->joinLeft(['ihp' => 'icinga_host_property'], 'ihp.property_uuid = dp.uuid', []) + ->columns([ + 'used_count' => 'COUNT(ihp.property_uuid)' + ]) + ->where('uuid = ?', $uuid->getBytes()); + + return (int) $db->fetchOne($query); + } + + protected function setTitleTab(string $name): void + { + $tab = $this->createTabs()->get($name); + + if ($tab !== null) { + $this->getTabs()->activate($name); + } + } + + protected function createTabs(): Tabs + { + $url = Url::fromPath('director/property', ['uuid' => $this->uuid->toString()]); + if ($this->parentUuid) { + $url->addParams(['parent_uuid' => $this->parentUuid->toString()]); + $label = $this->translate('Edit Field'); + } else { + $label = $this->translate('Property'); + } + + return $this->getTabs() + ->add('property', [ + 'label' => $label, + 'url' => $url + ]) + ->add('usage', [ + 'label' => $this->translate('Custom Variable Usage'), + 'url' => Url::fromPath('director/property/usage', ['uuid' => $this->uuid->toString()]) + ]); + } +} diff --git a/application/forms/CustomPropertiesForm.php b/application/forms/CustomPropertiesForm.php new file mode 100644 index 000000000..779e22c88 --- /dev/null +++ b/application/forms/CustomPropertiesForm.php @@ -0,0 +1,165 @@ +addAttributes(['class' => ['custom-properties-form']]); + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addElement((new Dictionary( + 'properties', + $this->objectProperties, + ['class' => 'no-border'] + ))->setAllowItemRemoval($this->object->isTemplate())); + + $this->addElement('submit', 'save', [ + 'label' => $this->translate('Save') + ]); + } + + /** + * Load form with object properties + * + * @param array $objectProperties + * + * @return void + */ + public function load(array $objectProperties): void + { + $this->populate([ + 'properties' => Dictionary::prepare($objectProperties) + ]); + } + + /** + * Filter empty values from array + * + * @param array $array + * + * @return array + */ + private function filterEmpty(array $array): array + { + return array_filter( + array_map(function ($item) { + if (! is_array($item)) { + // Recursively clean nested arrays + return $item; + } + + return $this->filterEmpty($item); + }, $array), + function ($item) { + return is_bool($item) || ! empty($item); + } + ); + } + + protected function onSuccess(): void + { + $vars = $this->object->vars(); + $inheritedVars = $this->object->getInheritedVars(); + + $modified = false; + + /** @var Dictionary $propertiesElement */ + $propertiesElement = $this->getElement('properties'); + $values = $propertiesElement->getDictionary(); + $itemsToRemove = $propertiesElement->getItemsToRemove(); + foreach ($this->objectProperties as $key => $property) { + if (isset($itemsToRemove[$key])) { + continue; + } + + $value = $values[$key] ?? null; + + if ( + is_array($value) + && ($property['value_type'] !== 'fixed-array' || isset($inheritedVars->$key)) + ) { + $value = $this->filterEmpty($value); + } + + if (! is_bool($value) && empty($value)) { + $vars->set($key, null); + } else { + $vars->set($key, $value); + } + + if ($vars->get($key) && $vars->get($key)->getUuid() === null && isset($property['uuid'])) { + $vars->registerVarUuid($key, Uuid::fromBytes($property['uuid'])); + } + + if ($modified === false && $vars->hasBeenModified()) { + $modified = true; + } + } + + DirectorActivityLog::logModification($this->object, $this->object->getConnection()); + if (! empty($itemsToRemove)) { + $objectId = (int) $this->object->get('id'); + $db = $this->object->getDb(); + + $objectsToCleanUp = [$objectId]; + $propertyAsHostVar = $db->fetchAll( + $db + ->select() + ->from('icinga_host_var') + ->where('property_uuid IN (?)', $itemsToRemove) + ); + + foreach ($propertyAsHostVar as $propertyAsHostVarRow) { + $host = IcingaHost::loadWithAutoIncId($propertyAsHostVarRow->host_id, $this->object->getConnection()); + + if (in_array($objectId, $host->listAncestorIds(), true)) { + $objectsToCleanUp[] = (int) $host->get('id'); + } + } + + $propertyWhere = $this->object->getDb()->quoteInto('property_uuid IN (?)', $itemsToRemove); + $objectsWhere = $this->object->getDb()->quoteInto('host_id IN (?)', $objectsToCleanUp); + $db->delete('icinga_host_var', $propertyWhere . ' AND ' . $objectsWhere); + + $objectWhere = $this->object->getDb()->quoteInto('host_uuid = ?', $this->object->get('uuid')); + $db->delete( + 'icinga_host_property', + $propertyWhere . ' AND ' . $objectWhere + ); + } + + $vars->storeToDb($this->object); + + if ($modified) { + Notification::success( + sprintf( + $this->translate('Custom variables have been successfully modified for %s'), + $this->object->getObjectName(), + ) + ); + } else { + Notification::success($this->translate('There is nothing to change.')); + } + } +} diff --git a/application/forms/DeletePropertyForm.php b/application/forms/DeletePropertyForm.php new file mode 100644 index 000000000..444cb6a79 --- /dev/null +++ b/application/forms/DeletePropertyForm.php @@ -0,0 +1,327 @@ +db->getDbAdapter(); + if ($this->parent) { + if ($this->parent['parent_uuid'] !== null) { + $uuid = $this->parent['parent_uuid']; + } else { + $uuid = $this->parent['uuid']; + } + } else { + $uuid = $this->property['uuid']; + } + + $customPropQuery = $db + ->select() + ->from(['ih' => 'icinga_host'], []) + ->join(['ihv' => 'icinga_host_var'], 'ih.id = ihv.host_id', []) + ->join(['dp' => 'director_property'], 'ihv.property_uuid = dp.uuid', []) + ->columns([ + 'name' => 'ih.object_name', + 'type' => 'ih.object_type' + ]) + ->where('dp.uuid = ?', Uuid::fromBytes($uuid)->getBytes()); + + $unionQuery = $db + ->select() + ->from(['ih' => 'icinga_host'], []) + ->join(['ihp' => 'icinga_host_property'], 'ihp.host_uuid = ih.uuid', []) + ->join(['dp' => 'director_property'], 'ihp.property_uuid = dp.uuid', []) + ->columns([ + 'name' => 'ih.object_name', + 'type' => 'ih.object_type' + ]) + ->where('dp.uuid = ?', $uuid); + + return $db->fetchAll($db->select()->union([$customPropQuery, $unionQuery])); + } + + protected function assemble(): void + { + $customVarUsage = $this->fetchCustomVarUsage(); + if (count($customVarUsage) > 0) { + if ($this->parent) { + if ($this->parent['parent_uuid'] !== null) { + $info = sprintf($this->translate( + 'Deleting this sub field from custom property "%s" will remove this field in' + . ' the corresponding custom variables from the below templates and objects.' + . ' Are you sure you want to delete it?' + ), $this->fetchProperty(Uuid::fromBytes($this->parent['parent_uuid']))['key_name']); + } else { + $info = sprintf($this->translate( + 'Deleting this field from custom property "%s" will remove this field in' + . ' the corresponding custom variables from the below templates and objects.' + . ' Are you sure you want to delete it?' + ), $this->parent['key_name']); + } + } else { + $info = $this->translate( + 'Deleting this custom property will remove the corresponding custom variable' + . ' from the below templates and objects. Are you sure you want to delete it?' + ); + } + } else { + if ($this->parent) { + $info = $this->translate('The field is not in use and hence can be safely deleted.'); + } else { + $info = $this->translate('The custom property is not in use and hence can be safely deleted.'); + } + } + + $this->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => 'form-description']), + new Icon('info-circle', ['class' => 'form-description-icon']), + new HtmlElement( + 'ul', + null, + new HtmlElement('li', null, Text::create($info)) + ) + )); + + $this->addHtml(new CustomVarObjectList($customVarUsage)); + + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Delete'), + 'class' => 'btn-remove' + ]); + } + + /** + * Fetch property for the given UUID + * + * @param UuidInterface $uuid UUID of the given property + * + * @return array + */ + private function fetchProperty(UuidInterface $uuid): array + { + $db = $this->db->getDbAdapter(); + + $query = $db + ->select() + ->from(['dp' => 'director_property'], []) + ->joinLeft(['ihp' => 'icinga_host_property'], 'ihp.property_uuid = dp.uuid', []) + ->columns([ + 'key_name', + 'uuid', + 'parent_uuid', + 'value_type', + 'label', + 'description' + ]) + ->where('uuid = ?', $uuid->getBytes()); + + return $db->fetchRow($query, [], Zend_Db::FETCH_ASSOC); + } + + /** + * Remove dictionary item from the give data array + * + * @param array $item + * @param array $path + * + * @return void + */ + private function removeDictionaryItem(array &$item, array $path): void + { + $key = array_shift($path); + + if (! array_key_exists($key, $item)) { + return; + } + + if (empty($path)) { + unset($item[$key]); + } elseif (is_array($item[$key])) { + $this->removeDictionaryItem($item[$key], $path); + } + + // Remove empty array items + if (isset($item[$key]) && empty($item[$key])) { + unset($item[$key]); + } + } + + protected function onSuccess(): void + { + $uuid = Uuid::fromBytes($this->property['uuid']); + $prop = $this->fetchProperty($uuid); + $db = $this->db->getDbAdapter(); + + $db->beginTransaction(); + $this->db->delete('director_property', Filter::where('uuid', $uuid->getBytes())); + $this->db->delete('director_property', Filter::where('parent_uuid', $uuid->getBytes())); + + if ($this->parent) { + if ($this->parent['parent_uuid'] !== null) { + // If the parent has in turn a parent + $rootUuid = Uuid::fromBytes($this->parent['parent_uuid']); + $rootProp = $this->fetchProperty($rootUuid); + $rootType = $rootProp['value_type']; + } else { + $rootType = $this->parent['value_type']; + $rootUuid = Uuid::fromBytes($this->parent['uuid']); + } + + $query = $db + ->select() + ->from(['ihv' => 'icinga_host_var'], []) + ->columns([ + 'host_id', + 'varname', + 'varvalue', + 'property_uuid' + ]) + ->where('property_uuid = ?', $rootUuid->getBytes()); + + $customVars = $db->fetchAll($query, [], Zend_Db::FETCH_ASSOC); + + foreach ($customVars as $customVar) { + $varValue = json_decode($customVar['varvalue'], true); + if ($rootType === 'dynamic-dictionary') { + foreach ($varValue as $key => $value) { + if ($this->parent['parent_uuid'] === null) { + $this->removeDictionaryItem($value, [$prop['key_name']]); + } else { + $this->removeDictionaryItem( + $value, + [$this->parent['key_name'], $prop['key_name']] + ); + } + + $varValue[$key] = $value; + } + } else { + if ($this->parent['parent_uuid'] === null) { + $this->removeDictionaryItem($varValue, [$prop['key_name']]); + } else { + $this->removeDictionaryItem( + $varValue, + [$this->parent['key_name'], $prop['key_name']] + ); + } + } + + if (empty($varValue)) { + $this->db->delete( + 'icinga_host_var', + Filter::matchAll( + Filter::where('property_uuid', $rootUuid->getBytes()), + Filter::where('host_id', $customVar['host_id']) + ) + ); + } else { + if ($this->parent && $this->parent['value_type'] === 'fixed-array') { + $this->updateFixedArrayItems(Uuid::fromBytes($this->parent['uuid'])); + $varValue[$this->parent['key_name']] = array_values($varValue[$this->parent['key_name']]); + } elseif ($rootType === 'fixed-array') { + $this->updateFixedArrayItems($rootUuid); + $varValue = array_values($varValue); + } + + $this->db->update( + 'icinga_host_var', + ['varvalue' => json_encode($varValue)], + Filter::matchAll( + Filter::where('property_uuid', $rootUuid->getBytes()), + Filter::where('host_id', $customVar['host_id']) + ) + ); + } + } + } + + $this->db->delete('icinga_host_var', Filter::where('property_uuid', $uuid->getBytes())); + $this->db->getDbAdapter()->commit(); + } + + /** + * Update the items for the given fixed array + * + * @param UuidInterface $uuid + * + * @return void + */ + private function updateFixedArrayItems(UuidInterface $uuid): void + { + $db = $this->db->getDbAdapter(); + $query = $db + ->select() + ->from(['dp' => 'director_property'], []) + ->joinLeft(['ihp' => 'icinga_host_property'], 'ihp.property_uuid = dp.uuid', []) + ->columns([ + 'key_name', + 'uuid', + 'parent_uuid', + 'value_type', + 'label', + 'description' + ]) + ->where('parent_uuid = ?', $uuid->getBytes()); + + $propItems = $db->fetchAll($query, [], Zend_Db::FETCH_ASSOC); + + $db->delete( + 'director_property', + ['parent_uuid = ?' => $uuid->getBytes()] + ); + + $count = 0; + foreach ($propItems as $propItem) { + $this->db->insert('director_property', [ + 'uuid' => Uuid::fromBytes($propItem['uuid'])->getBytes(), + 'parent_uuid' => $uuid->getBytes(), + 'key_name' => $count, + 'label' => $propItem['label'], + 'value_type' => $propItem['value_type'], + 'description' => $propItem['description'] + ]); + + $count++; + } + } +} diff --git a/application/forms/DictionaryElements/Dictionary.php b/application/forms/DictionaryElements/Dictionary.php new file mode 100644 index 000000000..805f86f5d --- /dev/null +++ b/application/forms/DictionaryElements/Dictionary.php @@ -0,0 +1,126 @@ + + */ +class Dictionary extends FieldsetElement +{ + protected $defaultAttributes = ['class' => 'dictionary']; + + /** @var array Dictionary items */ + protected array $items = []; + + /** @var bool Whether to allow removal of item */ + protected bool $allowItemRemoval = false; + + /** @var bool Whether the dictionary is an array */ + protected bool $isArray = false; + + public function __construct(string $name, array $items, $attributes = null) + { + $this->setItems($items); + + parent::__construct($name, $attributes); + } + + public function setAllowItemRemoval(bool $allow = false): static + { + $this->allowItemRemoval = $allow; + + return $this; + } + + /** + * Set the dictionary items + * + * @param array $items + * + * @return $this + */ + public function setItems(array $items): static + { + $this->items = $items; + + return $this; + } + + protected function assemble(): void + { + $count = 0; + foreach ($this->items as $item) { + $element = new DictionaryItem($count, $item); + + if ($this->allowItemRemoval && isset($item['allow_removal'])) { + $element->setRemovable($item['allow_removal']); + } + + $this->addElement($element); + $count++; + } + + if ($count === 0) { + $this->addHtml(new EmptyStateBar($this->translate('No fields configured'))); + } + } + + /** + * Prepare the dictionary for display + * + * @param array $items + * + * @return array + */ + public static function prepare(array $items): array + { + $values = []; + foreach ($items as $item) { + $values[] = DictionaryItem::prepare($item); + } + + return $values; + } + + public function getItemsToRemove(): array + { + $itemsToRemove = []; + + /** @var DictionaryItem $element */ + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof DictionaryItem) { + $item = $element->ensureAssembled()->getItem(); + if (isset($item['delete']) && $item['delete'] === 'y') { + $itemsToRemove[$item['name']] = $this->items[$item['name']]['uuid']; + } + } + } + + return $itemsToRemove; + } + + /** + * Get the dictionary value + * + * @return DictionaryDataType + */ + public function getDictionary(): array + { + $items = []; + + /** @var DictionaryItem $element */ + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof DictionaryItem) { + $item = $element->ensureAssembled()->getItem(); + if (isset($item['name']) && array_key_exists('value', $item)) { + $items[$item['name']] = $item['value']; + } + } + } + + return $items; + } +} diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php new file mode 100644 index 000000000..b0c07eb54 --- /dev/null +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -0,0 +1,286 @@ + ['no-border', 'dictionary-item']]; + + /** @var array Dictionary Item Fields */ + private $fields; + + /** @var bool Whether to allow removal of item */ + private bool $isRemovable = false; + + public function __construct(string $name, array $items, $attributes = null) + { + $this->fields = $items; + + parent::__construct($name, $attributes); + } + + public function setRemovable(bool $isRemovable = false): static + { + $this->isRemovable = $isRemovable; + + return $this; + } + + protected function assemble(): void + { + $this->addElement('hidden', 'name', ['value' => $this->fields['key_name'] ?? '']); + $this->addElement('hidden', 'type', ['value' => $this->fields['value_type'] ?? '']); + $this->addElement('hidden', 'label', ['value' => $this->fields['key_name'] ?? '']); + $this->addElement('hidden', 'parent_type', ['value' => $this->fields['parent_type'] ?? '']); + + $this->addElement('hidden', 'inherited'); + $this->addElement('hidden', 'inherited_from'); + + $valElementName = 'var'; + $type = $this->getElement('type')->getValue(); + $label = $this->getElement('label')->getValue(); + + if ($label === null) { + $label = $this->getElement('name')->getValue(); + } + + $children = static::fetchChildrenItems( + Uuid::fromBytes($this->fields['uuid']), + $this->fields['value_type'] ?? '' + ); + $inherited = $this->getElement('inherited')->getValue(); + $inheritedFrom = $this->getElement('inherited_from')->getValue(); + + $placeholder = ''; + if ($inherited) { + $placeholder = $inherited . ' (' . sprintf($this->translate('Inherited from %s'), $inheritedFrom) . ')'; + } + + if ($type === 'number') { + $this->addElement( + 'number', + $valElementName, + ['label' => $label . ' (Number)', 'placeholder' => $placeholder, 'step' => 'any'] + ); + } elseif ($type == 'bool') { + $this->addElement(new IplBoolean($valElementName, ['label' => $label, 'placeholder' => $placeholder])); + } elseif ($type === 'dynamic-array') { + $this->addElement((new ArrayElement($valElementName)) + ->setVerticalTermDirection() + ->setPlaceHolder($placeholder) + ->setLabel($label . ' (Array)')); + } elseif ($type === 'fixed-dictionary' || $type === 'fixed-array') { + $this->addElement( + (new Dictionary($valElementName, $children)) + ->setLabel($label . ' (' . ucfirst(substr($type, strlen('fixed-'))) . ')') + ); + } elseif ($type === 'dynamic-dictionary') { + $this->addElement((new NestedDictionary( + $valElementName, + $children, + ['inherited_from' => $inheritedFrom, 'value' => $inherited] + ))->setLabel($label . ' (Dictionary)')); + } else { + $this->addElement( + 'text', + $valElementName, + ['label' => $label . ' (' . ucfirst($type) . ')', 'placeholder' => $placeholder] + ); + } + + if ($this->isRemovable && ! isset($this->fields['parent_uuid'])) { + $markForRemoval = new CheckboxElement( + 'delete-' . $this->getName(), + [ + 'label' => 'Mark for removal', + 'description' => $this->translate( + 'Removing the custom variable from this template,' + . ' will also remove it from the objects importing the template' + ), + ] + ); + + $this->registerElement($markForRemoval); + $this->addElement($markForRemoval); + } + } + + /** + * Prepare the dictionary item for display + * + * @param array $property + * + * @return array + */ + public static function prepare(array $property): array + { + $values = [ + 'name' => $property['key_name'] ?? '', + 'label' => $property['label'] ?? '', + 'type' => $property['value_type'] ?? '', + 'parent_type' => $property['parent_type'] ?? '' + ]; + + if ($property['value_type'] === 'dynamic-array') { + $values['var'] = $property['value'] ?? []; + $values['inherited'] = implode(', ', $property['inherited'] ?? []); + $values['inherited_from'] = $property['inherited_from'] ?? ''; + } elseif ($property['value_type'] === 'fixed-dictionary' || $property['value_type'] === 'fixed-array') { + $childrenValues = ['value' => $property['value'] ?? []]; + + if (! isset($property['value'])) { + $childrenValues['inherited'] = $property['inherited'] ?? []; + $childrenValues['inherited_from'] = $property['inherited_from'] ?? ''; + } + + $dictionaryItems = static::fetchChildrenItems( + Uuid::fromBytes($property['uuid']), + $property['value_type'], + $childrenValues + ); + $values['var'] = Dictionary::prepare($dictionaryItems); + } elseif ($property['value_type'] === 'dynamic-dictionary') { + $childrenValues = [ + 'value' => $property['value'] ?? [], + 'inherited' => $property['inherited'] ?? [], + 'inherited_from' => $property['inherited_from'] ?? '' + ]; + + $dictionaryItems = static::fetchChildrenItems( + Uuid::fromBytes($property['uuid']), + $property['value_type'], + $childrenValues + ); + $values['var'] = NestedDictionary::prepare( + $dictionaryItems, + $property['value'] ?? [] + ); + + $values['inherited'] = isset($property['inherited']) + ? json_encode($property['inherited'], JSON_PRETTY_PRINT) + : ''; + $values['inherited_from'] = $property['inherited_from'] ?? ''; + } else { + $values['var'] = $property['value'] ?? ''; + $values['inherited'] = $property['inherited'] ?? ''; + $values['inherited_from'] = $property['inherited_from'] ?? ''; + } + + return $values; + } + + /** + * Fetch children items of the given parent item + * + * @param UuidInterface $parentUuid + * @param string $parentType + * @param array $values + * + * @return array + */ + public static function fetchChildrenItems(UuidInterface $parentUuid, string $parentType, array $values = []): array + { + $db = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); + + $query = $db->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label', + 'parent_uuid' => 'dp.parent_uuid', + 'children' => 'COUNT(cdp.uuid)' + ] + ) + ->where('dp.parent_uuid = ?', $parentUuid->getBytes()) + ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->order('children') + ->order('key_name'); + + $propertyItems = $db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + if (empty($values)) { + return $propertyItems; + } + + $result = []; + foreach ($propertyItems as $propertyItem) { + $propertyItem['parent_type'] = $parentType; + if (isset($values['value'][$propertyItem['key_name']])) { + $propertyItem['value'] = $values['value'][$propertyItem['key_name']]; + } + + if (isset($values['inherited'][$propertyItem['key_name']])) { + $propertyItem['inherited'] = $values['inherited'][$propertyItem['key_name']]; + $propertyItem['inherited_from'] = $values['inherited_from']; + } + + $result[$propertyItem['key_name']] = $propertyItem; + } + + return $result; + } + + /** + * Get the dictionary item value + * + * @return DictionaryItemDataType + */ + public function getItem(): array + { + $values = ['name' => $this->getElement('name')->getValue()]; + $itemValue = $this->getElement('var'); + if ($itemValue instanceof NestedDictionary or $itemValue instanceof Dictionary) { + $values['value'] = $itemValue->getDictionary(); + + if ($this->getElement('type')->getValue() === 'fixed-array') { + $value = $values['value']; + ksort($value); + $values['value'] = array_values($value); + } + } else { + if (! empty($this->getElement('inherited')->getValue())) { + $values['value'] = $itemValue->getValue(); + } else { + $defaultValue = null; + + // Use the default value for fixed-array items only if the fixed array does not have an inherited value + if ($this->getElement('parent_type')->getValue() === 'fixed-array') { + match ($this->getElement('type')->getValue()) { + 'string' => $defaultValue = '', + 'number' => $defaultValue = 0, + 'bool' => $defaultValue = 'n', + 'fixed-array', 'dynamic-array' => $defaultValue = [] + }; + } + + $values['value'] = $itemValue->getValue() ?? $defaultValue; + } + } + + $markForRemovalElement = 'delete-' . $this->getName(); + if ($this->hasElement($markForRemovalElement)) { + $values['delete'] = $this->getElement($markForRemovalElement)->getValue(); + } + + return $values; + } +} diff --git a/application/forms/DictionaryElements/NestedDictionary.php b/application/forms/DictionaryElements/NestedDictionary.php new file mode 100644 index 000000000..afab01620 --- /dev/null +++ b/application/forms/DictionaryElements/NestedDictionary.php @@ -0,0 +1,182 @@ + ['nested-dictionary', 'nested-fieldset']]; + + /** @var array Nested dictionary items */ + protected $nestedItems = []; + + /** @var array{inherited_from: string, value: array} Inherited value */ + protected array $inheritedValue; + + public function __construct( + string $name, + array $nestedItems, + array $inheritedValues, + $attributes = null + ) { + $this->inheritedValue = $inheritedValues; + $this->nestedItems = $nestedItems; + + parent::__construct($name, $attributes); + } + + protected function assemble(): void + { + $expectedCount = (int) $this->getPopulatedValue('count', 0); + $count = 0; + $newCount = 0; + + if (! empty($this->inheritedValue['value'])) { + $inheritedFrom = implode( + ', ', + array_map( + fn($item) => '"' . trim($item) . '"', + explode(',', $this->inheritedValue['inherited_from']) + ) + ); + + $this->addElement( + 'textarea', + 'inherited_value', + [ + 'label' => sprintf( + $this->translate('Inherited from %s'), + $inheritedFrom + ), + 'value' => $this->inheritedValue['value'], + 'class' => 'inherited-value', + 'readonly' => true, + 'rows' => 10 + ] + ); + } + + while ($count < $expectedCount) { + $remove = $this->createElement( + 'submitButton', + 'remove_' . $count + ); + + $this->registerElement($remove); + if ($remove->hasBeenPressed()) { + $removedValue = $this->getPopulatedValue($count); + $clearedId = null; + if (isset($removedValue['id'])) { + $clearedId = $removedValue['id']; + } + + $this->clearPopulatedValue($remove->getName()); + $this->clearPopulatedValue($count); + + // Re-index populated values to ensure proper association with form data + foreach (range($count + 1, $expectedCount) as $i) { + $newPopulatedValue = $this->getPopulatedValue($count); + $newId = $newPopulatedValue['id'] ?? null; + $newPopulatedValue['id'] = $clearedId; + $this->populate([$i - 1 => $this->getPopulatedValue($i) ?? []]); + $clearedId = $newId; + } + } else { + $newCount++; + } + + $count++; + } + + $addButton = $this->createElement('submitButton', 'add_property', [ + 'label' => $this->translate('Add Property'), + 'class' => ['add-property'], + 'formnovalidate' => true + ]); + + $this->registerElement($addButton); + + if ($addButton->hasBeenPressed()) { + $remove = $this->createElement('submitButton', 'remove_' . $newCount, ['label' => 'Remove Item']); + $this->registerElement($remove); + $newCount++; + } + + for ($i = 0; $i < $newCount; $i++) { + $nestedDictionaryProperty = new NestedDictionaryItem($i, $this->nestedItems); + $nestedDictionaryProperty->setRemoveButton($this->getElement('remove_' . $i)); + $this->addElement($nestedDictionaryProperty); + } + + if ($newCount === 0) { + $this->addHtml(new EmptyStateBar($this->translate('No items added'))); + } + + $this->addElement($addButton); + + $this->clearPopulatedValue('count'); + $this->addElement('hidden', 'count', ['ignore' => true, 'value' => $newCount]); + } + + /** + * Prepare nested dictionary for display + * + * @param array $nestedItems + * @param array $values + * + * @return array + */ + public static function prepare(array $nestedItems, array $values): array + { + $result = []; + foreach ($values as $key => $nestedValue) { + $nestedValue['key'] = $key; + $result[] = NestedDictionaryItem::prepare( + $nestedItems, + $nestedValue + ); + } + + return $result; + } + + public function populate($values): static + { + if (! isset($values['count'])) { + $values['count'] = count($values); + } + + return parent::populate($values); + } + + /** + * Get the nested dictionary value + * + * @return array + */ + public function getDictionary(): array + { + $values = []; + $count = 0; + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof NestedDictionaryItem) { + $property = $element->getItem(); + if (! empty($property['key']) && array_key_exists('value', $property)) { + $values[$property['key']] = $property['value']; + } else { + $values[$count] = $property['value']; + } + + $count++; + } + } + + return $values; + } +} diff --git a/application/forms/DictionaryElements/NestedDictionaryItem.php b/application/forms/DictionaryElements/NestedDictionaryItem.php new file mode 100644 index 000000000..3e81efd9b --- /dev/null +++ b/application/forms/DictionaryElements/NestedDictionaryItem.php @@ -0,0 +1,140 @@ + ['nested-dictionary-item', 'collapsible']]; + + /** @var array Items in the nested dictionary property */ + protected array $items = []; + + /** @var ?SubmitButtonElement Remove button for the nested dictionary property*/ + private ?SubmitButtonElement $removeButton = null; + + public function __construct(string $name, array $items, $attributes = null) + { + $this->items = $items; + + $this->getAttributes()->add([ + 'data-toggle-element' => 'legend', + 'data-visible-height' => 0 + ]); + + parent::__construct($name, $attributes); + } + + protected function assemble(): void + { + $this->addElement('text', 'key', [ + 'label' => $this->translate('Key'), + 'required' => true + ]); + + $this->addElement('hidden', 'state', [ + 'value' => $this->getPopulatedValue('key') ? 'old' : 'new', + 'ignore' => true + ]); + + if ($this->getElement('state')->getValue() === 'old') { + $label = $this->getElement('key')->getValue(); + $id = str_replace(['[', ']'], '_', $this->getElement('key')->getValue()); + } else { + $label = $this->translate('New Item'); + if ($this->getPopulatedValue('id') == null) { + $id = uniqid('id-'); + } else { + $id = $this->getPopulatedValue('id'); + } + } + + $this->addElement('hidden', 'id', ['value' => $id]); + $this->getAttributes()->set('id', $id); + + $this->setLabel($label); + if ($this->removeButton !== null) { + $this->addHtml(new HtmlElement( + 'div', + null, + $this->removeButton->setLabel(new Icon('trash')) + ->setAttribute('formnovalidate', true) + ->setAttribute('class', ['remove-button']) + ->add(Text::create(' ' . $this->translate('Remove'))) + )); + } + + $this->addElement( + (new Dictionary('var', $this->items, ['class' => 'no-border'])) + ->setItems($this->items) + ); + } + + /** + * Set the remove button. + * + * @param ?FormElement $removeButton + * + * @return $this + */ + public function setRemoveButton(?FormElement $removeButton): static + { + $this->removeButton = $removeButton; + + return $this; + } + + /** + * Prepare the nested dictionary item value for display + * + * @param array $nestedItems + * @param array $property + * + * @return array + */ + public static function prepare(array $nestedItems, array $property): array + { + $nestedValues = []; + foreach ($nestedItems as $nestedItem) { + if (isset($property[$nestedItem['key_name']]) && ! empty($property[$nestedItem['key_name']])) { + $nestedItem['value'] = $property[$nestedItem['key_name']]; + } + + $nestedValues[] = $nestedItem; + } + + return [ + 'key' => $property['key'], + 'var' => Dictionary::prepare($nestedValues) + ]; + } + + /** + * Get the nested dictionary item value + * + * @return NestedDictionaryItemDataType + */ + public function getItem(): array + { + $this->ensureAssembled(); + $key = $this->getElement('key')->getValue(); + $values = []; + $values['key'] = $key; + $values['value'] = $this->getElement('var')->getDictionary(); + + return $values; + } +} diff --git a/application/forms/IcingaServiceForm.php b/application/forms/IcingaServiceForm.php index 3e21bc8cd..8cd77b3a3 100644 --- a/application/forms/IcingaServiceForm.php +++ b/application/forms/IcingaServiceForm.php @@ -7,7 +7,6 @@ use Icinga\Exception\IcingaException; use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Auth\Permission; -use Icinga\Module\Director\Data\PropertiesFilter\ArrayCustomVariablesFilter; use Icinga\Module\Director\Exception\NestingError; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Web\Form\DirectorObjectForm; @@ -15,8 +14,12 @@ use Icinga\Module\Director\Objects\IcingaService; use Icinga\Module\Director\Objects\IcingaServiceSet; use Icinga\Module\Director\Web\Table\ObjectsTableHost; +use ipl\Html\Attributes; use ipl\Html\Html; use gipfl\IcingaWeb2\Link; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use PDO; use RuntimeException; class IcingaServiceForm extends DirectorObjectForm @@ -40,6 +43,8 @@ class IcingaServiceForm extends DirectorObjectForm /** @var bool|null */ private $blacklisted; + private $dictionaryUuidMap = []; + public function setApplyGenerated(IcingaService $applyGenerated) { $this->applyGenerated = $applyGenerated; @@ -612,6 +617,54 @@ protected function addHostObjectElement() return $this; } + protected function applyForVars(): ?array + { + $query = $this->db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label' + ] + ) + ->join(['iop' => 'icinga_host_property'], 'dp.uuid = iop.property_uuid', []) + ->where("value_type IN ('dynamic-array', 'dynamic-dictionary')"); + + $vars = $this->db->getDbAdapter()->fetchAll($query); + + $properties = []; + foreach ($vars as $var) { + $properties['host.vars.' . $var->key_name] = $var->label ?? $var->key_name . ' (' . $var->key_name . ')'; + if ($var->value_type === 'dynamic-dictionary') { + $this->dictionaryUuidMap['host.vars.' . $var->key_name] = $var->uuid; + } + } + + return [t('director', 'Custom variables') => $properties]; + } + + + + private function fetchNestedDictionaryKeys(string $dictionaryUuid) + { + $query = $this->db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'uuid' => 'dp.uuid', + 'key_name' => 'dp.key_name', + 'label' => 'dp.label', + 'value_type' => 'dp.value_type' + ] + )->where("parent_uuid = ?", $dictionaryUuid); + + return $this->db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + } + /** * @return $this * @throws \Zend_Form_Exception @@ -619,24 +672,128 @@ protected function addHostObjectElement() protected function addApplyForElement() { if ($this->object->isApplyRule()) { - $hostProperties = IcingaHost::enumProperties( - $this->object->getConnection(), - 'host.', - new ArrayCustomVariablesFilter() - ); + $hostProperties = $this->applyForVars(); - $this->addElement('select', 'apply_for', array( + $this->addElement('select', 'apply_for', [ '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.' + 'E.g selecting "host.vars.custom_attr" will generate "for (value in ' . + 'host.vars.array_var)" where "value" will be accessible through "$value$". ' . + 'NOTE: only custom variables of type "Array" and "Dictionary" are eligible.' ) - )); + ]); + + if ($this->hasBeenSent()) { + $applyFor = $this->getRequest()->getPost('apply_for'); + } else { + $applyFor = $this->object->get('apply_for'); + } + + $content = []; + if (isset($this->dictionaryUuidMap[$applyFor])) { + $dictionaryKeys = $this->fetchNestedDictionaryKeys($this->dictionaryUuidMap[$applyFor]); + + if (! empty($dictionaryKeys)) { + $configVariables = new HtmlElement('ul', Attributes::create(['class' => 'nested-key-list'])); + foreach ($dictionaryKeys as $keyAttributes) { + if (str_contains($keyAttributes['key_name'], ' ')) { + continue; + } + + $config = '$value.' . $keyAttributes['key_name']; + $content = [ + new HtmlElement('div', null, Text::create( + $keyAttributes['label'] ?? $keyAttributes['key_name'] + . ' (' + . $keyAttributes['key_name'] + . ')' + )), + new HtmlElement('div', null, Text::create('=>')) + ]; + + if ($keyAttributes['value_type'] === 'fixed-dictionary') { + $nestedContent = []; + + foreach ($this->fetchNestedDictionaryKeys($keyAttributes['uuid']) as $nestedKeyAttributes) { + if (str_contains($nestedKeyAttributes['key_name'], ' ')) { + continue; + } + + $nestedConfig = $config . '.' . $nestedKeyAttributes['key_name'] . '$'; + $nestedContent[] = new HtmlElement('div', null, Text::create($nestedConfig)); + + $nestedContent = [ + new HtmlElement('div', null, Text::create( + $nestedKeyAttributes['label'] ?? $nestedKeyAttributes['key_name'] + . ' (' + . $nestedKeyAttributes['key_name'] + . ')' + )), + new HtmlElement('div', null, Text::create('=>')), + new HtmlElement('div', null, Text::create( + $nestedConfig + )) + ]; + } + + $content[] = new HtmlElement( + 'div', + null, + new HtmlElement('div', null, Text::create( + '$value.' + . $keyAttributes['key_name'] + . '$' + )), + new HtmlElement( + 'ul', + null, + new HtmlElement('li', null, ...$nestedContent) + ) + ); + } else { + if (str_contains($keyAttributes['key_name'], ' ')) { + $config = '$value["' . $keyAttributes['key_name'] . '"]$'; + } else { + $config = '$value.' . $keyAttributes['key_name'] . '$'; + } + + $content[] = new HtmlElement('div', null, Text::create($config)); + } + + $configVariables->addHtml(new HtmlElement('li', null, ...$content)); + } + + if (empty($content)) { + return $this; + } + + $this->addHtmlHint( + HtmlElement::create( + 'div', + null, + [ + Text::create($this->translate( + 'Nested keys of selected host dictionary variable for apply-for-rule' + . ' are accessible through value as shown below:' + )), + $configVariables + ] + ), + ['name' => 'apply_for_hint'] + ); + + $this->addElementsToGroup( + ['apply_for_hint'], + 'custom_fields', + DirectorObjectForm::GROUP_ORDER_CUSTOM_FIELDS, + $this->translate('Custom properties') + ); + } + } } return $this; diff --git a/application/forms/ObjectPropertyForm.php b/application/forms/ObjectPropertyForm.php new file mode 100644 index 000000000..d0f34d019 --- /dev/null +++ b/application/forms/ObjectPropertyForm.php @@ -0,0 +1,104 @@ +properties = $this->getProperties(); + } + + public function getPropertyName(): string + { + $propertyUuid = $this->getValue('property'); + if ($propertyUuid) { + return $this->properties[$propertyUuid] ?? ''; + } + + return ''; + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $propertyElement = $this->createElement( + 'select', + 'property', + [ + 'label' => $this->translate('Property'), + 'required' => true, + 'class' => ['autosubmit'], + 'disabledOptions' => [''], + 'value' => '', + 'options' => array_merge( + ['' => $this->translate('Please choose a property')], + $this->getProperties() + ) + ] + ); + + $this->addElement($propertyElement); + + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Add') + ]); + } + + protected function getProperties(): array + { + $parents = $this->object->listAncestorIds(); + + $uuids = []; + $db = $this->db->getDbAdapter(); + foreach ($parents as $parent) { + $uuids[] = IcingaHost::load($parent, $this->object->getConnection())->get('uuid'); + } + + $query = $db + ->select() + ->from(['dp' => 'director_property'], ['uuid' => 'dp.uuid', 'key_name' => 'dp.key_name']) + ->joinLeft(['iop' => 'icinga_host_property'], 'dp.uuid = iop.property_uuid') + ->where('parent_uuid IS NULL'); + + if (! empty($uuids)) { + $query->where('iop.host_uuid NOT IN (?) OR iop.host_uuid IS NULL', $uuids); + } + + $properties = $db->fetchAll($query); + $propUuidKeyPairs = []; + foreach ($properties as $property) { + $propUuidKeyPairs[Uuid::fromBytes($property->uuid)->toString()] = $property->key_name; + } + + return $propUuidKeyPairs; + } + + protected function onSuccess() + { + $this->db->insert( + 'icinga_host_property', + [ + 'host_uuid' => $this->object->uuid, + 'property_uuid' => Uuid::fromString($this->getValue('property'))->getBytes() + ] + ); + } +} diff --git a/application/forms/PropertyForm.php b/application/forms/PropertyForm.php new file mode 100644 index 000000000..7dba09dba --- /dev/null +++ b/application/forms/PropertyForm.php @@ -0,0 +1,441 @@ +addAttributes(['class' => ['property-form']]); + } + + /** + * Get the UUID of the property + * + * @return ?UuidInterface + */ + public function getUUid(): ?UuidInterface + { + return $this->uuid; + } + + /** + * Get UUID of the parent property + * + * @return ?UuidInterface + */ + public function getParentUUid(): ?UuidInterface + { + return $this->parentUuid; + } + + /** + * Set whether to hide the key name element or not (checked for the fixed array) + * + * @param bool $hideKeyNameElement + * + * @return $this + */ + public function setHideKeyNameElement(bool $hideKeyNameElement): self + { + $this->hideKeyNameElement = $hideKeyNameElement; + + return $this; + } + + /** + * Set whether the field is a nested field (field in a sub dictionary) or not + * + * @param bool $isNestedField + * + * @return $this + */ + public function setIsNestedField(bool $isNestedField): self + { + $this->isNestedField = $isNestedField; + + return $this; + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addElement('hidden', 'used_count', ['ignore' => true]); + $used = (int) $this->getValue('used_count') > 0; + + if ($this->hideKeyNameElement) { + $db = $this->db->getDbAdapter(); + $query = $db->select() + ->from('director_property', ['count' => 'COUNT(*)']) + ->where('parent_uuid = ?', $this->parentUuid->getBytes()); + + $this->addElement( + 'hidden', + 'key_name', + [ + 'label' => $this->translate('Property Key *'), + 'required' => true, + 'value' => $db->fetchOne($query) + ] + ); + } else { + $this->addElement( + 'text', + 'key_name', + [ + 'label' => $this->translate('Property Key *'), + 'required' => true + ] + ); + } + + $this->addElement( + 'text', + 'label', + [ + 'label' => $this->translate('Property Label'), + 'required' => $this->hideKeyNameElement + ] + ); + + $this->addElement( + 'textarea', + 'description', + ['label' => $this->translate('Property Description')] + ); + + $types = [ + 'string' => 'String', + 'number' => 'Number', + 'bool' => 'Boolean', + ]; + + if (! $this->isNestedField) { + $types += [ + 'fixed-array' => 'Fixed Array', + 'dynamic-array' => 'Dynamic Array', + 'fixed-dictionary' => 'Fixed Dictionary' + ]; + + if ($this->parentUuid === null) { + $types += [ + 'dynamic-dictionary' => 'Dynamic Dictionary' + ]; + } + } + + $this->addElement( + 'select', + 'value_type', + [ + 'label' => $this->translate('Property Type *'), + 'class' => 'autosubmit', + 'required' => true, + 'disabledOptions' => [''], + 'value' => 'string', + 'options' => $types + ] + ); + + if ($used) { + $this->getElement('value_type') + ->setAttribute( + 'title', + $this->translate( + 'This property is used in one or more templates and hence the value type cannot be changed.' + ) + ) + ->setAttribute('disabled', true); + } + + $type = $this->getValue('value_type'); + if ($type === 'dynamic-array') { + $this->addElement( + 'select', + 'item_type', + [ + 'label' => $this->translate('Item Type'), + 'class' => 'autosubmit', + 'disabledOptions' => [''], + 'value' => 'string', + 'options' => array_slice($types, 0, 2) + ] + ); + + if ($used) { + $this->getElement('item_type') + ->setAttribute( + 'title', + $this->translate( + 'This property is used in one or more templates and hence the item type cannot be changed.' + ) + ) + ->setAttribute('disabled', true); + } + } + + $this->addElement('submit', 'submit', [ + 'label' => $this->uuid ? $this->translate('Save') : $this->translate('Add') + ]); + + if ($this->uuid) { + // TODO: Ask for confirmation before deleting + $this->getElement('submit') + ->getWrapper() + ->prepend( + (new ButtonLink( + $this->translate('Delete'), + Url::fromPath( + 'director/property/delete', + ['uuid' => $this->uuid->toString()] + ), + null, + ['class' => ['btn-remove']] + ))->openInModal() + ); + } + } + + /** + * Fetch property for the given UUID + * + * @param UuidInterface $uuid UUID of the given property + * + * @return array + */ + private function fetchProperty(UuidInterface $uuid): array + { + $db = $this->db->getDbAdapter(); + + $query = $db + ->select() + ->from(['dp' => 'director_property'], []) + ->joinLeft(['ihp' => 'icinga_host_property'], 'ihp.property_uuid = dp.uuid', []) + ->columns([ + 'key_name', + 'uuid', + 'parent_uuid', + 'value_type', + 'label', + 'description' + ]) + ->where('uuid = ?', $uuid->getBytes()); + + return $db->fetchRow($query, [], Zend_Db::FETCH_ASSOC); + } + + private function updateObjectCustomVars(array $path, array $newPath, array &$item): void + { + $key = array_shift($path); + $newKey = array_shift($newPath); + + if (! array_key_exists($key, $item)) { + return; + } + + if (empty($path) && empty($newPath) && $key !== $newKey) { + $item[$newKey] = $item[$key]; + unset($item[$key]); + } elseif (is_array($item[$key])) { + $this->updateObjectCustomVars($path, $newPath, $item[$key]); + } + + // Remove empty array items + if (isset($item[$key]) && empty($item[$key])) { + unset($item[$key]); + } + } + + protected function onSuccess(): void + { + $values = $this->getValues(); + + if ($this->uuid === null) { + $this->uuid = Uuid::uuid4(); + if ($this->field) { + $values = array_merge( + [ + 'uuid' => $this->uuid->getBytes(), + 'parent_uuid' => $this->parentUuid->getBytes() + ], + $values + ); + } else { + $values = array_merge( + ['uuid' => $this->uuid->getBytes()], + $values + ); + } + + $dynamicArrayItemType = []; + if (isset($values['item_type'])) { + $dynamicArrayItemType = [ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '0', + 'value_type' => $values['item_type'], + 'parent_uuid' => $this->uuid->getBytes() + ]; + + unset($values['item_type']); + } + + $this->db->insert('director_property', $values); + + if (! empty($dynamicArrayItemType)) { + $this->db->insert('director_property', $dynamicArrayItemType); + } + } else { + unset($values['used_count']); + + $used = $this->getValue('used_count') > 0; + if (! $used) { + $dbProperty = $this->fetchProperty($this->uuid); + if ( + $dbProperty['value_type'] !== $values['value_type'] + || $dbProperty['value_type'] === 'dynamic-array' + ) { + $this->db->delete( + 'director_property', + Filter::matchAll( + Filter::where('parent_uuid', $this->uuid->getBytes()), + ) + ); + } + + if (isset($values['item_type']) && $values['value_type'] === 'dynamic-array') { + $this->db->insert('director_property', [ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '0', + 'value_type' => $values['item_type'], + 'parent_uuid' => $this->uuid->getBytes() + ]); + + unset($values['item_type']); + } + } else { + $this->db->getDbAdapter()->beginTransaction(); + $storedKeyName = $this->db->fetchOne( + $this->db->select() + ->from('director_property', ['key_name']) + ->where('uuid', $this->uuid->getBytes()) + ); + + if ($storedKeyName !== $values['key_name']) { + $db = $this->db->getDbAdapter(); + $parent = []; + if (! $this->parentUuid) { + $rootUuid = $this->uuid; + } elseif ($this->isNestedField) { + $parent = $this->fetchProperty($this->parentUuid); + $rootUuid = Uuid::fromBytes($parent['parent_uuid']); + } else { + $rootUuid = $this->parentUuid; + } + + $root = $this->fetchProperty($rootUuid); + + $objectCustomVars = $db->fetchAll( + $db->select() + ->from(['ihv' => 'icinga_host_var'], []) + ->columns([ + 'host_id', + 'varname', + 'varvalue', + 'property_uuid' + ]) + ->where('property_uuid = ?', $rootUuid->getBytes()), + [], + PDO::FETCH_ASSOC + ); + + if (! $this->parentUuid) { + foreach ($objectCustomVars as $objectCustomVar) { + $this->db->update( + 'icinga_host_var', + ['varname' => $values['key_name']], + Filter::matchAll( + Filter::where('property_uuid', $rootUuid->getBytes()), + Filter::where('host_id', $objectCustomVar['host_id']) + ) + ); + } + } else { + foreach ($objectCustomVars as $objectCustomVar) { + $varValue = json_decode($objectCustomVar['varvalue'], true); + if ($root['value_type'] === 'dynamic-dictionary') { + foreach ($varValue as $key => $value) { + if ($this->isNestedField) { + $parenKey = $parent['key_name']; + $this->updateObjectCustomVars( + [$parenKey, $storedKeyName], + [$parenKey, $values['key_name']], + $value + ); + } else { + $this->updateObjectCustomVars([$storedKeyName], [$values['key_name']], $value); + } + + $varValue[$key] = $value; + } + } else { + if ($this->isNestedField) { + $this->updateObjectCustomVars([$storedKeyName], [$values['key_name']], $varValue); + } else { + $this->updateObjectCustomVars([$storedKeyName], [$values['key_name']], $varValue); + } + } + + $this->db->update( + 'icinga_host_var', + ['varvalue' => json_encode($varValue)], + Filter::matchAll( + Filter::where('property_uuid', $rootUuid->getBytes()), + Filter::where('host_id', $objectCustomVar['host_id']) + ) + ); + } + } + } + } + + $this->db->update( + 'director_property', + $values, + Filter::where('uuid', $this->uuid->getBytes()) + ); + + $this->db->getDbAdapter()->commit(); + } + } +} diff --git a/configuration.php b/configuration.php index f812f3c44..e51e133e6 100644 --- a/configuration.php +++ b/configuration.php @@ -175,3 +175,15 @@ ->setUrl('director/config/deployments') ->setPriority(902) ->setPermission(Permission::DEPLOYMENTS); +$section->add(N_('Properties')) + ->setUrl('director/properties') + ->setPriority(903); + +$cssDirectory = $this->getCssDir(); +$cssFiles = new RecursiveIteratorIterator(new RecursiveDirectoryIterator( + $cssDirectory, + RecursiveDirectoryIterator::CURRENT_AS_PATHNAME | RecursiveDirectoryIterator::SKIP_DOTS +)); +foreach ($cssFiles as $path) { + $this->provideCssFile(ltrim(substr($path, strlen($cssDirectory)), DIRECTORY_SEPARATOR)); +} diff --git a/doc/New-Custom-Property-Support.md b/doc/New-Custom-Property-Support.md new file mode 100644 index 000000000..4c8b4acae --- /dev/null +++ b/doc/New-Custom-Property-Support.md @@ -0,0 +1,442 @@ +## Introduction + +Custom variables are an integral part in Icinga 2 to control how the monitoring is performed on an object. There are different types of custom variables. +1. String +2. Number +3. Boolean +4. Array +5. Dictionary + +Currently, Icinga Director supports all these types and also allows categorization of these variables based of data categories and pre-configuring a list of values as data list and using them in arrays or string. And much more. +However there is a limited support to dictionaries. + +Moreover the apply-for rules can only be configured for arrays and not the dictionaries. + +The new implementation for custom properties with the dictionary support in Icinga Director includes the following: + +- A new menu section called `Properties` under Icinga Director to configure the properties for objects instead of configuring them as data fields. +- The supported property types are string, number, boolean, fixed-array, dynamic-array, fixed-dictionary and dynamic-dictionary. +- As already mentioned above array and dictionary property types are either: + 1. **Fixed**: You could only assign values to the preconfigured items + 2. **Dynamic**: Here the items are added by users in the monitored objects directly. In case of dictionaries, each added item follows the {key => value} structure. And the value is a dictionary with a preconfigured items. + +- The custom property list also shows whether they are being used in a template or not. + +- Features not yet included: Inclusion of data list and data categories + +- Only one level of nesting is allowed: a dictionary can contain other dictionaries, but those inner dictionaries can contain only non-dictionary values. +- The new custom property support works only for Hosts currently +- The Apply-for rules can now be configured for these dictionaries and on that note they could be configured only for dynamic arrays and dynamic dictionaries. +- `config` is changed to `value` in apply-for rule. + +- A new endpoint to update custom variables of a Host using Rest API called `variables` is now available. +- Configuration baskets also supports new custom property implementation, but only for host templates. Creating a configuration basket and then creating snapshot for the adds also the custom properties used in the corresponding host templates to the snapshot. +- The automation is not largely affected right now. Since the data lists feature is currently not available in the new custom property feature, the imported data lists cannot be used in the new custom properties. +- The strategy for the migration has not been finalized yet and is under discussion. However, the data fields of type 'string', 'number' and 'bool' which have no data category, should be somehow migrated to custom properties, either by providing an REST API end point to create them once exported or need other methods. +- For later, consider implementing conflict check for custom properties on basket snapshot restore. + +## Example Custom Properties Configuration +- First, checkout to the 'dictionary-support' branch of director and apply the schema migration. +- To configure the new custom properties, navigate to Icinga Director->Properties as shown below: +![NewProperty](img/NewProperty.png) +- Click on 'Create property' to add properties +- Below is an example of 'core_disk' dynamic dictionary. Once the dictionary is added, add the fields that each 'core_disk' might contain, by clicking on 'Add Field'. +![CoreDisk](img/coreDiskFields.png) +- I have added one more field 'contact_group' as dynamic array, which will be an array of strings. +![ContactGroups](img/contactGroups.png) + +## Adding Custom Properties to Host + +- To add the custom properties to host, they must be added to the host template after it is configured. +- There is a separate 'Custom Variables' where the custom properties could be added and configured for the host. As shown in the screenshot below. +![HostAddCustomVariable](img/hostAddCustomVariable.png) +- Click on 'Add Property' button and add the custom properties for the given host. I have added 'core_disks' and 'Http checks' custom variables for this 'generic-host-template'. +![GenericCustomVariable](img/genericCustomVariables.png) +- Once this template or any of its child templates is used in host, the corresponding custom variables or inherited and are available for configuration as shown in screenshots below. + +![ExampleHost](img/exampleHost.png) +![ExampleHostCustomVariable](img/exampleHostCustomVariables.png) + + +## Updating the Custom Properties of a Host using Rest API + +- To update the custom properties of the host 'server-1' using Rest API, execute the below curl command. + +
+ +``` +curl -k -u 'icingaadmin:icinga' -H 'Accept: application/json' -H 'Content-Type: application/json' -X PUT 'http://localhost/icingaweb2/director/host/variables?name=server-1' -d '{ + "disk_check": { + "disk1": { + "auth": { + "Pass": "pass", + "user": "admin" + }, + "critical": "20", + "warning": "15" + }, + "disk2": { + "auth": { + "Pass": "test", + "user": "test" + }, + "critical": "20", + "warning": "10" + } + }, + "contact_groups": [ "test", "admin" ] +}' +``` +
+ +## Apply-for Rules for the disk_check dictionary + +- Configure 'generic-service' template. +- The data fields for service are configured as it was usually being done. Here, I have configured all the necessary custom variables as string type data fields and added them to the 'generic-service' template. The reason I have configured the data fields as string type is because all these custom variables will inherit the properties from the host disk_check custom variable, which will be shown in the following steps. +![ServiceDataFields](img/serviceDatafields.png) +![ServiceCustomVariable](img/serviceCustomVariables.png) + +- Now configure the apply rule for the host disk_check custom variable as shown below. As shown in the screenshots below the items from the host.var.disk_check as accessed through $value.$ (This is shown in the hint below the Custom properties section). And hence the data fields were configured as string type. + +![ApplyRule](img/applyRule.png) +![NewProperties](img/applyRuleConfig.png) + +-After they are configured, deploy them. The deployed host and services are shown in the screenshot below. +![DeployedHostAndServices](img/deployedHostAndServices.png) + + + + +## Use Cases for the fixed dictionary, dynamic dictionary and fixed array + +### Fixed Dictionary + +A dictionary in according to the [Icinga 2 Docs](https://icinga.com/docs/icinga-2/latest/doc/17-language-reference/#array) is an unordered list of key-value pairs. Hence the custom variables of this type is stored as key-value pair. Below are the few use cases. + +1. Configuring database connection parameters: +``` + vars.mysql = { + user = "dev" + password = "password123" + database = "dev" + } +``` + +The below screenshot shows the configuration for the custom property `mysql`, if its type is fixed-dictionary. ![FixedArray](img/fixedDictionary.png) + +2. Configuring contacts in contact groups +``` + vars.contacts = { + admins = [ "alice", "bob" ] + ops = [ "carol" ] + } +``` + +### Dynamic Dictionary + +The value here is always a dictionary, whose structure is preconfigured. Below are some use cases: +1. Configuring ssh arguments for the ssh check used to check disk free space +Example: +``` + vars.by_ssh_arguments = { + "-c" = { + description = "Critical threshold" + value = "5%" + }, + "-w" = { + description = "Warning threshold" + value = "15%" + } + } +``` +2. Configuring virtual hosts for http check +``` + vars.http_vhosts += { + bar = { + direct_notify = false + http_address = "foo-bar.com" + } + test = { + direct_notify = true + http_address = "example.com" + http_expect = [ "HTTP/1.0 200", "HTTP/1.1 200" ] + http_port = "443" + http_uri = "/api" + } + } +``` + +The dynamic dictionary is assigned differently compared to other custom properties. + +For the other type the custom properties are defined in the template and the values are overwritten in the hosts or templates importing the base template. + +In case of dynamic dictionary, the values are not overwritten instead they are appended. + +The values of the custom variable of dynamic-dictionary type are merged when a host or template imports multiple templates that share a common base template where the variable was originally defined. If those imported templates also add new items to that variable, all the additions from each level are combined on the host. See the mermaid diagram below depicting the same. + +For example: + +```mermaid + classDiagram + generic-host-template --|> child-host-template-1 + generic-host-template --|> child-host-template-2 + child-host-template-1 --|> host-a + child-host-template-2 --|> host-a + generic-host-template: vars.core_disk + class child-host-template-1 { + vars.core_disk += #123; +   '/' = #123; +      'disk_cfree' = '10' +      'disk_wfree' = '20' +      'disk_partition' = '/' +   #125; + #125; + } + class child-host-template-2 { + vars.core_disk = #123; +   'local' += #123; +      'disk_cfree' = '10' +      'disk_wfree' = '20' +      'disk_partition' = 'local' +   #125; + #125; + } + class host-a{ + vars.core_disk = #123; +   '/' = #123; +      'disk_cfree' = '10' +      'disk_wfree' = '20' +      'disk_partition' = '/' +   #125; +   'local' = #123; +      'disk_cfree' = '10' +      'disk_wfree' = '20' +      'disk_partition' = 'local' +   #125; + #125; + } +``` + +### Fixed Array + +An array in according to the [Icinga 2 Docs](https://icinga.com/docs/icinga-2/latest/doc/17-language-reference/#array) is an ordered list of values. Hence the custom variables of this type store only values, not keys. However, a predefined structure can be used to assign meaning to each value in the array. This type of custom variable has similar use case to fixed dictionary, but the custom variable will be saved as array instead without the keys. + +Example: + +``` + vars.mysql = ["dev", "password123", "dev"] +``` + +The below screenshot shows the configuration for the custom property `mysql`, if its type is fixed-array. ![FixedArray](img/fixedArray.png) + + + +### Dynamic Array + +Here, you could added many values of the same type to the array. In other words it must be an uniform array. + +Example: + +``` + vars.contact_groups = ["admin", "prod", "dev"] +``` + +The below screenshot shows the configuration for the custom property `contact_groups`. + +![ContactGroups](img/contactGroups.png) + +## Example of Configuration Basket + +The below screenshot show an example of the configuration basket snapshot for host templates. + +**Configuration Basket:** +![Basket](img/Basket.png) + +**Basket Snapshot:** +![Snapshot](img/Snapshot.png) + +**Basket Snapshot Content:** +

+"HostTemplate": {
+    "generic-host-template": {
+      "check_command": "random fortune",
+      "max_check_attempts": "25",
+      "object_name": "generic-host-template",
+      "object_type": "template",
+    
+ "properties": [ + { + "property_uuid": "65504893-934e-4af0-aabe-f87b18e53d89" + }, + { + "property_uuid": "f729a7f4-ba34-400f-bb41-b27d0dd4cdcf" + } + ], +
+ "uuid": "93cda885-1738-438a-8c9e-13aec1de7e4b", + "vars": { + "core_disks": { + "first": { + "disk_cfree": "10", + "disk_inode_cfree": "/opt/splunk" + } + }, + "http_vhosts": { + "foo": { + "http_address": "/opt/splunk", + "http_expect": [ + "HTTP/1.0 200", + "HTTP/1.1 200" + ] + } + } + } + }, + "sub-template-1": { + "check_command": "random fortune", + "imports": [ + "generic-host-template" + ], + "object_name": "sub-template-1", + "object_type": "template", + "properties": [], + "uuid": "d798c43a-6b0f-4504-bbc7-a0785b9e1792", + "vars": { + "core_disks": { + "second": { + "disk_cfree": "20" + } + } + } + }, + "sub-template-2": { + "object_name": "sub-template-2", + "object_type": "template", + "properties": [], + "uuid": "db357026-4360-44e9-bcf0-4405b9f68a2c", + "vars": { + "core_disks": { + "third": { + "disk_cfree": "30" + } + } + } + } + }, +
+"Property": { + "65504893-934e-4af0-aabe-f87b18e53d89": { + "uuid": "65504893-934e-4af0-aabe-f87b18e53d89", + "key_name": "core_disks", + "parent_uuid": null, + "value_type": "dynamic-dictionary", + "label": "core_disks", + "description": null, + "items": { + "disk_cfree": { + "uuid": "6a865d74-edfa-4039-b89e-b93834b8aa1e", + "key_name": "disk_cfree", + "parent_uuid": "65504893-934e-4af0-aabe-f87b18e53d89", + "value_type": "string", + "label": "disk_cfree", + "description": null, + "items": [] + }, + "disk_exclude_type": { + "uuid": "9adb3cca-f233-4409-be77-21716bcc4da7", + "key_name": "disk_exclude_type", + "parent_uuid": "65504893-934e-4af0-aabe-f87b18e53d89", + "value_type": "dynamic-array", + "label": "disk_exclude_type", + "description": null, + "items": [ + { + "uuid": "8d242573-8349-4458-95b7-0d7c4f5bd475", + "key_name": "0", + "parent_uuid": "9adb3cca-f233-4409-be77-21716bcc4da7", + "value_type": "string", + "label": null, + "description": null, + "items": [] + } + ] + }, + "disk_inode_cfree": { + "uuid": "9b09004f-c952-40fb-88e7-438d39867981", + "key_name": "disk_inode_cfree", + "parent_uuid": "65504893-934e-4af0-aabe-f87b18e53d89", + "value_type": "string", + "label": "disk_inode_cfree", + "description": null, + "items": [] + }, + "disk_wfree": { + "uuid": "af4f1eef-0f6f-4ee9-a65f-5366ff74c1f1", + "key_name": "disk_wfree", + "parent_uuid": "65504893-934e-4af0-aabe-f87b18e53d89", + "value_type": "string", + "label": "disk_wfree", + "description": null, + "items": [] + }, + "disk_partitions": { + "uuid": "ce54bb5a-b8d2-474d-b09e-d1e6d23756e3", + "key_name": "disk_partitions", + "parent_uuid": "65504893-934e-4af0-aabe-f87b18e53d89", + "value_type": "string", + "label": "disk_partitions", + "description": null, + "items": [] + } + } + }, + "f729a7f4-ba34-400f-bb41-b27d0dd4cdcf": { + "uuid": "f729a7f4-ba34-400f-bb41-b27d0dd4cdcf", + "key_name": "http_vhosts", + "parent_uuid": null, + "value_type": "dynamic-dictionary", + "label": "Http Checks", + "description": null, + "items": { + "http_address": { + "uuid": "d922877c-0063-45d0-85be-2637edda3eb0", + "key_name": "http_address", + "parent_uuid": "f729a7f4-ba34-400f-bb41-b27d0dd4cdcf", + "value_type": "string", + "label": "http_address", + "description": null, + "items": [] + }, + "http_expect": { + "uuid": "f08a9545-1d63-4c45-aa1d-e565d8171a45", + "key_name": "http_expect", + "parent_uuid": "f729a7f4-ba34-400f-bb41-b27d0dd4cdcf", + "value_type": "dynamic-array", + "label": "http_expect", + "description": null, + "items": [] + } + } + } + } +
+} +
+ + +## Conclusion +- Apply-for rules for services are working. +- Configuration baskets also support the new custom properties for host templates +- New 'variables' (director/host/variables) endpoint for hosts have been provided to update only the custom variables for the hosts. + +The implementation does not cover everything currently. It does not include the following: +- There is no support for data list, data field category +- The new custom property works only for Hosts +- The visibility of the custom properties like password cannot be changed. They are always visible. diff --git a/doc/img/Basket.png b/doc/img/Basket.png new file mode 100644 index 000000000..de86675ce Binary files /dev/null and b/doc/img/Basket.png differ diff --git a/doc/img/CreateProperty.png b/doc/img/CreateProperty.png new file mode 100644 index 000000000..ef308f0cd Binary files /dev/null and b/doc/img/CreateProperty.png differ diff --git a/doc/img/NewProperty.png b/doc/img/NewProperty.png new file mode 100644 index 000000000..ec2d66fe3 Binary files /dev/null and b/doc/img/NewProperty.png differ diff --git a/doc/img/PropertyValueTypeList.png b/doc/img/PropertyValueTypeList.png new file mode 100644 index 000000000..da04f63a5 Binary files /dev/null and b/doc/img/PropertyValueTypeList.png differ diff --git a/doc/img/Snapshot.png b/doc/img/Snapshot.png new file mode 100644 index 000000000..56602026a Binary files /dev/null and b/doc/img/Snapshot.png differ diff --git a/doc/img/SnapshotContent.png b/doc/img/SnapshotContent.png new file mode 100644 index 000000000..8a923e26b Binary files /dev/null and b/doc/img/SnapshotContent.png differ diff --git a/doc/img/applyRule.png b/doc/img/applyRule.png new file mode 100644 index 000000000..4b481558c Binary files /dev/null and b/doc/img/applyRule.png differ diff --git a/doc/img/applyRuleConfig.png b/doc/img/applyRuleConfig.png new file mode 100644 index 000000000..13f157774 Binary files /dev/null and b/doc/img/applyRuleConfig.png differ diff --git a/doc/img/contactGroups.png b/doc/img/contactGroups.png new file mode 100644 index 000000000..c845dbc22 Binary files /dev/null and b/doc/img/contactGroups.png differ diff --git a/doc/img/coreDiskFields.png b/doc/img/coreDiskFields.png new file mode 100644 index 000000000..c0d43de95 Binary files /dev/null and b/doc/img/coreDiskFields.png differ diff --git a/doc/img/deployedHostAndServices.png b/doc/img/deployedHostAndServices.png new file mode 100644 index 000000000..48885c403 Binary files /dev/null and b/doc/img/deployedHostAndServices.png differ diff --git a/doc/img/exampleHost.png b/doc/img/exampleHost.png new file mode 100644 index 000000000..b6f738d8c Binary files /dev/null and b/doc/img/exampleHost.png differ diff --git a/doc/img/exampleHostCustomVariables.png b/doc/img/exampleHostCustomVariables.png new file mode 100644 index 000000000..f12da3d7b Binary files /dev/null and b/doc/img/exampleHostCustomVariables.png differ diff --git a/doc/img/fixedArray.png b/doc/img/fixedArray.png new file mode 100644 index 000000000..3f719a7b1 Binary files /dev/null and b/doc/img/fixedArray.png differ diff --git a/doc/img/fixedDictionary.png b/doc/img/fixedDictionary.png new file mode 100644 index 000000000..b86d95398 Binary files /dev/null and b/doc/img/fixedDictionary.png differ diff --git a/doc/img/genericCustomVariables.png b/doc/img/genericCustomVariables.png new file mode 100644 index 000000000..20c07b34a Binary files /dev/null and b/doc/img/genericCustomVariables.png differ diff --git a/doc/img/hostAddCustomVariable.png b/doc/img/hostAddCustomVariable.png new file mode 100644 index 000000000..debefe028 Binary files /dev/null and b/doc/img/hostAddCustomVariable.png differ diff --git a/doc/img/server1AddCustomVariables.png b/doc/img/server1AddCustomVariables.png new file mode 100644 index 000000000..e807330f2 Binary files /dev/null and b/doc/img/server1AddCustomVariables.png differ diff --git a/doc/img/server1Config.png b/doc/img/server1Config.png new file mode 100644 index 000000000..064469a86 Binary files /dev/null and b/doc/img/server1Config.png differ diff --git a/doc/img/server1CustomVariables.png b/doc/img/server1CustomVariables.png new file mode 100644 index 000000000..caeb6a41a Binary files /dev/null and b/doc/img/server1CustomVariables.png differ diff --git a/doc/img/serviceCustomVariables.png b/doc/img/serviceCustomVariables.png new file mode 100644 index 000000000..97ccc10a4 Binary files /dev/null and b/doc/img/serviceCustomVariables.png differ diff --git a/doc/img/serviceDataFields.png b/doc/img/serviceDataFields.png new file mode 100644 index 000000000..ff9b2eded Binary files /dev/null and b/doc/img/serviceDataFields.png differ diff --git a/library/Director/CustomVariable/CustomVariable.php b/library/Director/CustomVariable/CustomVariable.php index 4b5dd3e43..14e38d52e 100644 --- a/library/Director/CustomVariable/CustomVariable.php +++ b/library/Director/CustomVariable/CustomVariable.php @@ -8,11 +8,16 @@ use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer; use InvalidArgumentException; use LogicException; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; abstract class CustomVariable implements IcingaConfigRenderer { protected $key; + /** @var ?UuidInterface */ + protected $uuid; + protected $value; protected $storedValue; @@ -88,6 +93,24 @@ public function getKey() return $this->key; } + /** + * Get the UUID of the custom property linked to the custom variable + * + * @return ?UuidInterface + */ + public function getUuid(): ?UuidInterface + { + return $this->uuid; + } + + public function setUuid(UuidInterface $uuid): static + { + $this->uuid = $uuid; + $this->modified = true; + + return $this; + } + /** * @param $value * @return $this @@ -240,7 +263,14 @@ public static function create($key, $value) } } - public static function fromDbRow($row) + /** + * Create a CustomVariable instance from a database row object. + * + * @param object $row The database row object containing the custom variable data. + * + * @return CustomVariable The constructed CustomVariable instance. + */ + public static function fromDbRow(object $row): CustomVariable { switch ($row->format) { case 'string': @@ -259,12 +289,18 @@ public static function fromDbRow($row) $row->format )); } + if (property_exists($row, 'checksum')) { $var->setChecksum($row->checksum); } + if (property_exists($row, 'property_uuid') && $row->property_uuid) { + $var->setUuid(Uuid::fromBytes($row->property_uuid)); + } + $var->loadedFromDb = true; $var->setUnmodified(); + return $var; } diff --git a/library/Director/CustomVariable/CustomVariableArray.php b/library/Director/CustomVariable/CustomVariableArray.php index 7e430a4ec..0abc66cf4 100644 --- a/library/Director/CustomVariable/CustomVariableArray.php +++ b/library/Director/CustomVariable/CustomVariableArray.php @@ -8,7 +8,7 @@ class CustomVariableArray extends CustomVariable { /** @var CustomVariable[] */ - protected $value; + protected $value = []; public function equals(CustomVariable $var) { diff --git a/library/Director/CustomVariable/CustomVariableDictionary.php b/library/Director/CustomVariable/CustomVariableDictionary.php index d84be4ff3..f79000d42 100644 --- a/library/Director/CustomVariable/CustomVariableDictionary.php +++ b/library/Director/CustomVariable/CustomVariableDictionary.php @@ -5,11 +5,12 @@ use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; use Countable; +use Icinga\Module\Director\Objects\IcingaObject; class CustomVariableDictionary extends CustomVariable implements Countable { /** @var CustomVariable[] */ - protected $value; + protected $value = []; public function equals(CustomVariable $var) { @@ -66,7 +67,6 @@ public function setValue($value) public function getValue() { $ret = (object) array(); - ksort($this->value); foreach ($this->value as $key => $var) { $ret->$key = $var->getValue(); diff --git a/library/Director/CustomVariable/CustomVariableString.php b/library/Director/CustomVariable/CustomVariableString.php index 2d509681c..88e238952 100644 --- a/library/Director/CustomVariable/CustomVariableString.php +++ b/library/Director/CustomVariable/CustomVariableString.php @@ -7,6 +7,15 @@ class CustomVariableString extends CustomVariable { + private $whiteList = []; + + protected function __construct($key, $value = null, $whiteList = []) + { + parent::__construct($key, $value); + + $this->whiteList = $whiteList; + } + public function equals(CustomVariable $var) { if (! $var instanceof CustomVariableString) { @@ -46,7 +55,7 @@ public function flatten(array &$flat, $prefix) public function toConfigString($renderExpressions = false) { if ($renderExpressions) { - return c::renderStringWithVariables($this->getValue(), ['config']); + return c::renderStringWithVariables($this->getValue(), $this->whiteList); } else { return c::renderString($this->getValue()); } @@ -56,4 +65,11 @@ public function toLegacyConfigString() { return c1::renderString($this->getValue()); } + + public function setWhiteList(array $whiteList): self + { + $this->whiteList = $whiteList; + + return $this; + } } diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php index bb0b44b34..b08446b05 100644 --- a/library/Director/CustomVariable/CustomVariables.php +++ b/library/Director/CustomVariable/CustomVariables.php @@ -10,6 +10,7 @@ use Countable; use Exception; use Iterator; +use Ramsey\Uuid\UuidInterface; class CustomVariables implements Iterator, Countable, IcingaConfigRenderer { @@ -27,6 +28,8 @@ class CustomVariables implements Iterator, Countable, IcingaConfigRenderer protected $idx = array(); + private $whiteList = []; + protected static $allTables = array( 'icinga_command_var', 'icinga_host_var', @@ -159,6 +162,18 @@ public function set($key, $value) return $this; } + public function setWhiteList(array $whitelist): self + { + $this->whiteList = $whitelist; + + return $this; + } + + public function getWhiteList(array $whitelist): array + { + return $this->whiteList; + } + protected function refreshIndex() { $this->idx = array(); @@ -174,13 +189,21 @@ public static function loadForStoredObject(IcingaObject $object) { $db = $object->getDb(); + $type = $object->getShortTableName(); + $columns = [ + 'v.' . $type . '_id', + 'v.varname', + 'v.varvalue', + 'v.format' + ]; + + if ($type === 'host') { + $columns[] = 'property_uuid'; + } + $query = $db->select()->from( - array('v' => $object->getVarsTableName()), - array( - 'v.varname', - 'v.varvalue', - 'v.format', - ) + ['v' => $object->getVarsTableName()], + $columns )->where(sprintf('v.%s = ?', $object->getVarsIdColumn()), $object->get('id')); $vars = new CustomVariables(); @@ -213,17 +236,22 @@ public function storeToDb(IcingaObject $object) foreach ($this->vars as $var) { + $uuid = $var->getUuid()?->getBytes(); if ($var->isNew()) { - $db->insert( - $table, - array( - $foreignColumn => $foreignId, - 'varname' => $var->getKey(), - 'varvalue' => $var->getDbValue(), - 'format' => $var->getDbFormat() - ) - ); + $row = [ + $foreignColumn => $foreignId, + 'varname' => $var->getKey(), + 'varvalue' => $var->getDbValue(), + 'format' => $var->getDbFormat() + ]; + + if ($object->getShortTableName() === 'host' && $uuid) { + $row['property_uuid'] = $uuid; + } + + $db->insert($table, $row); $var->setLoadedFromDb(); + continue; } @@ -233,12 +261,18 @@ public function storeToDb(IcingaObject $object) if ($var->hasBeenDeleted()) { $db->delete($table, $where); } elseif ($var->hasBeenModified()) { + $data = [ + 'varvalue' => $var->getDbValue(), + 'format' => $var->getDbFormat() + ]; + + if ($object->getShortTableName() === 'host' && $uuid) { + $data['property_uuid'] = $uuid; + } + $db->update( $table, - array( - 'varvalue' => $var->getDbValue(), - 'format' => $var->getDbFormat() - ), + $data, $where ); } @@ -341,13 +375,13 @@ public function setOverrideKeyName($name) return $this; } - public function toConfigString($renderExpressions = false) + public function toConfigString(?IcingaObject $object = null, $renderExpressions = false) { $out = ''; foreach ($this as $key => $var) { // TODO: ctype_alnum + underscore? - $out .= $this->renderSingleVar($key, $var, $renderExpressions); + $out .= $this->renderSingleVar($key, $var, $object, $renderExpressions); } return $out; @@ -396,14 +430,56 @@ public function toLegacyConfigString() * * @return string */ - protected function renderSingleVar($key, $var, $renderExpressions = false) + protected function renderSingleVar($key, $var, ?IcingaObject $object = null, $renderExpressions = false) { + if ($var instanceof CustomVariableString) { + $var->setWhiteList($this->whiteList); + } + if ($key === $this->overrideKeyName) { return c::renderKeyOperatorValue( $this->renderKeyName($key), '+=', $var->toConfigStringPrefetchable($renderExpressions) ); + } elseif ($var instanceof CustomVariableDictionary) { + if ($object === null || ($object->getShortTableName() !== 'host')) { + return c::renderKeyValue( + $this->renderKeyName($key), + $var->toConfigStringPrefetchable($renderExpressions) + ); + } elseif ($object->getShortTableName() === 'host') { + $type = $object->getShortTableName(); + $objectId = $object->get('id'); + $ids = $object->listAncestorIds() + [$object->get('id')]; + $query = $object->getDb()->select()->from( + ['dp' => 'director_property'], + ['value_type'] + ) + ->join(['iop' => 'icinga_' . $type . '_property'], 'dp.uuid = iop.property_uuid', []) + ->join(['io' => 'icinga_' . $type], 'iop.' . $type . '_uuid = io.uuid', ['object_id' => 'io.id']) + ->join(['iov' => 'icinga_' . $type . '_var'], 'iov.' . $type . '_id = io.id', []) + ->where('dp.key_name = ?', $var->getKey()) + ->where('io.id IN (?)', $ids); + + $row = (array) $object->getDb()->fetchRow($query); + if ( + isset($row['value_type']) + && $row['value_type'] === 'dynamic-dictionary' + && $objectId !== $row['object_id'] + ) { + return c::renderKeyOperatorValue( + $this->renderKeyName($key), + '+=', + $var->toConfigStringPrefetchable($renderExpressions) + ); + } else { + return c::renderKeyValue( + $this->renderKeyName($key), + $var->toConfigStringPrefetchable($renderExpressions) + ); + } + } } else { return c::renderKeyValue( $this->renderKeyName($key), @@ -475,6 +551,21 @@ public function __unset($key) $this->refreshIndex(); } + /** + * Register the UUID of the given variable + * + * @param string $key + * @param UuidInterface $uuid + * + * @return void + */ + public function registerVarUuid(string $key, UuidInterface $uuid): static + { + $this->vars[$key]->setUuid($uuid); + + return $this; + } + public function __toString() { try { diff --git a/library/Director/Data/Exporter.php b/library/Director/Data/Exporter.php index 1a3cfcb7d..5db6bb76a 100644 --- a/library/Director/Data/Exporter.php +++ b/library/Director/Data/Exporter.php @@ -14,6 +14,7 @@ use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Objects\DirectorDatalistEntry; use Icinga\Module\Director\Objects\DirectorJob; +use Icinga\Module\Director\Objects\DirectorProperty; use Icinga\Module\Director\Objects\IcingaCommand; use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaObject; @@ -33,6 +34,9 @@ class Exporter /** @var FieldReferenceLoader */ protected $fieldReferenceLoader; + /** @var PropertyReferenceLoader */ + protected $propertyReferenceLoader; + /** @var ?HostServiceLoader */ protected $serviceLoader = null; @@ -52,6 +56,7 @@ public function __construct(Db $connection) $this->connection = $connection; $this->db = $connection->getDbAdapter(); $this->fieldReferenceLoader = new FieldReferenceLoader($connection); + $this->propertyReferenceLoader = new PropertyReferenceLoader($connection); } public function export(DbObject $object) @@ -274,6 +279,11 @@ protected function exportDbObject(DbObject $object) $props['objects'] = JsonString::decode($props['objects']); } } + + if ($object instanceof DirectorProperty && $props['parent_uuid'] !== null) { + $props['parent_uuid'] = Uuid::fromBytes($props['parent_uuid'])->toString(); + } + unset($props['uuid']); // Not yet if (! $this->showDefaults) { foreach ($props as $key => $value) { @@ -296,7 +306,11 @@ protected function exportIcingaObject(IcingaObject $object) { $props = (array) $object->toPlainObject($this->resolveObjects, !$this->showDefaults); if ($object->supportsFields()) { - $props['fields'] = $this->fieldReferenceLoader->loadFor($object); + if ($object instanceof IcingaHost) { + $props['properties'] = $this->propertyReferenceLoader->loadFor($object); + } else { + $props['fields'] = $this->fieldReferenceLoader->loadFor($object); + } } return $props; diff --git a/library/Director/Data/ObjectImporter.php b/library/Director/Data/ObjectImporter.php index bd9f87c39..1b9e1b750 100644 --- a/library/Director/Data/ObjectImporter.php +++ b/library/Director/Data/ObjectImporter.php @@ -56,6 +56,10 @@ public function import(string $implementation, stdClass $plain): DbObject $properties = (array) $plain; unset($properties['fields']); unset($properties['originalId']); + if ($object instanceof IcingaHost) { + unset($properties['properties']); + } + if ($implementation === Basket::class) { if (isset($properties['objects']) && is_string($properties['objects'])) { $properties['objects'] = JsonString::decode($properties['objects']); diff --git a/library/Director/Data/PropertyReferenceLoader.php b/library/Director/Data/PropertyReferenceLoader.php new file mode 100644 index 000000000..ecc9e34fa --- /dev/null +++ b/library/Director/Data/PropertyReferenceLoader.php @@ -0,0 +1,55 @@ +db = $connection->getDbAdapter(); + } + + /** + * Load properties referenced by the object + * + * @param IcingaObject $object + * + * @return array + */ + public function loadFor(IcingaObject $object): array + { + $db = $this->db; + $uuid = $object->get('uuid'); + if ($uuid === null) { + return []; + } + + $type = $object->getShortTableName(); + $res = $db->fetchAll( + $db->select()->from(['f' => "icinga_{$type}_property"], [ + 'f.property_uuid', + ])->join(['df' => 'director_property'], 'df.uuid = f.property_uuid', []) + ->where("{$type}_uuid = ?", $uuid) + ->order('key_name ASC') + ); + + if (empty($res)) { + return []; + } + + foreach ($res as $key => $property) { + $property->property_uuid = Uuid::fromBytes($property->property_uuid)->toString(); + $res[$key] = $property; + } + + return $res; + } +} diff --git a/library/Director/DirectorObject/Automation/Basket.php b/library/Director/DirectorObject/Automation/Basket.php index cfa71e7ef..904de6ad6 100644 --- a/library/Director/DirectorObject/Automation/Basket.php +++ b/library/Director/DirectorObject/Automation/Basket.php @@ -60,7 +60,8 @@ public function isEmpty() protected function onLoadFromDb() { $this->chosenObjects = (array) Json::decode($this->get('objects')); - unset($this->chosenObjects['Datafield']); // Might be in old baskets + unset($this->chosenObjects['Datafield']); + unset($this->chosenObjects['Property']);// Might be in old baskets } public function getUniqueIdentifier() diff --git a/library/Director/DirectorObject/Automation/BasketDiff.php b/library/Director/DirectorObject/Automation/BasketDiff.php index 8dbb42362..646824317 100644 --- a/library/Director/DirectorObject/Automation/BasketDiff.php +++ b/library/Director/DirectorObject/Automation/BasketDiff.php @@ -25,6 +25,9 @@ class BasketDiff /** @var BasketSnapshotFieldResolver */ protected $fieldResolver; + /** @var BasketSnapshotCustomPropertyResolver */ + protected $customPropertyResolver; + public function __construct(BasketSnapshot $snapshot, Db $db) { $this->db = $db; @@ -58,14 +61,28 @@ protected function getFieldResolver(): BasketSnapshotFieldResolver return $this->fieldResolver; } + protected function getCustomPropertyResolver(): BasketSnapshotCustomPropertyResolver + { + if ($this->customPropertyResolver === null) { + $this->customPropertyResolver = new BasketSnapshotCustomPropertyResolver( + $this->getBasketObjects(), + $this->db + ); + } + + return $this->customPropertyResolver; + } + protected function getCurrent(string $type, string $key, ?UuidInterface $uuid = null): ?stdClass { if ($uuid && $current = BasketSnapshot::instanceByUuid($type, $uuid, $this->db)) { $exported = $this->exporter->export($current); $this->getFieldResolver()->tweakTargetIds($exported); + $this->getCustomPropertyResolver()->tweakTargetUuids($exported); } elseif ($current = BasketSnapshot::instanceByIdentifier($type, $key, $this->db)) { $exported = $this->exporter->export($current); $this->getFieldResolver()->tweakTargetIds($exported); + $this->getCustomPropertyResolver()->tweakTargetUuids($exported); } else { $exported = null; } @@ -78,6 +95,7 @@ protected function getBasket($type, $key): stdClass { $object = $this->getBasketObject($type, $key); $fields = $object->fields ?? null; + $properties = $object->properties ?? null; $reExport = $this->exporter->export( $this->importer->import(BasketSnapshot::getClassForType($type), $object) ); @@ -86,6 +104,13 @@ protected function getBasket($type, $key): stdClass } else { $reExport->fields = $fields; } + + if ($properties === null) { + unset($reExport->properties); + } else { + $reExport->properties = $properties; + } + CompareBasketObject::normalize($reExport); return $reExport; diff --git a/library/Director/DirectorObject/Automation/BasketSnapshot.php b/library/Director/DirectorObject/Automation/BasketSnapshot.php index 266397984..5c2415242 100644 --- a/library/Director/DirectorObject/Automation/BasketSnapshot.php +++ b/library/Director/DirectorObject/Automation/BasketSnapshot.php @@ -10,6 +10,7 @@ use Icinga\Module\Director\Db; use Icinga\Module\Director\Data\Db\DbObject; use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\DirectorProperty; use Icinga\Module\Director\Objects\DirectorDatafieldCategory; use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Objects\DirectorJob; @@ -39,6 +40,7 @@ class BasketSnapshot extends DbObject protected static $typeClasses = [ 'DatafieldCategory' => DirectorDatafieldCategory::class, 'Datafield' => DirectorDatafield::class, + 'Property' => DirectorProperty::class, 'TimePeriod' => IcingaTimePeriod::class, 'CommandTemplate' => [IcingaCommand::class, ['object_type' => 'template']], 'ExternalCommand' => [IcingaCommand::class, ['object_type' => 'external_object']], @@ -154,6 +156,7 @@ public static function createForBasket(Basket $basket, Db $db) ], $db); $snapshot->addObjectsChosenByBasket($basket); $snapshot->resolveRequiredFields(); + $snapshot->resolveRequiredProperties(); return $snapshot; } @@ -184,6 +187,27 @@ protected function resolveRequiredFields() } } + /** + * @throws \Icinga\Exception\NotFoundError + */ + protected function resolveRequiredProperties() + { + /** @var Db $db */ + $db = $this->getConnection(); + $customPropertyResolver = new BasketSnapshotCustomPropertyResolver($this->objects, $db); + + /** @var DirectorProperty[] $properties */ + $properties = $customPropertyResolver->loadCurrentProperties($db); + if (! empty($properties)) { + $plain = []; + foreach ($properties as $uuid => $customProperty) { + $plain[$uuid] = $customProperty->export(); + } + + $this->objects['Property'] = $plain; + } + } + protected function addObjectsChosenByBasket(Basket $basket) { foreach ($basket->getChosenObjects() as $typeName => $selection) { @@ -237,6 +261,7 @@ public static function forBasketFromJson(Basket $basket, $string) $snapshot = static::create([ 'basket_uuid' => $basket->get('uuid') ]); + $snapshot->objects = []; foreach ((array) JsonString::decode($string) as $type => $objects) { $snapshot->objects[$type] = (array) $objects; @@ -261,11 +286,13 @@ protected function restoreObjects(stdClass $all, Db $connection) $db = $connection->getDbAdapter(); $db->beginTransaction(); $fieldResolver = new BasketSnapshotFieldResolver($all, $connection); + $propertyResolver = new BasketSnapshotCustomPropertyResolver($all, $connection); $this->restoreType($all, 'DataList', $fieldResolver, $connection); $this->restoreType($all, 'DatafieldCategory', $fieldResolver, $connection); $fieldResolver->storeNewFields(); + $propertyResolver->storeNewProperties(); foreach ($this->restoreOrder as $typeName) { - $this->restoreType($all, $typeName, $fieldResolver, $connection); + $this->restoreType($all, $typeName, $fieldResolver, $connection, $propertyResolver); } $db->commit(); } @@ -280,7 +307,8 @@ public function restoreType( stdClass $all, string $typeName, BasketSnapshotFieldResolver $fieldResolver, - Db $connection + Db $connection, + BasketSnapshotCustomPropertyResolver $customPropertyResolver = null ) { if (isset($all->$typeName)) { $objects = (array) $all->$typeName; @@ -302,6 +330,10 @@ public function restoreType( if ($new instanceof IcingaObject) { $fieldResolver->relinkObjectFields($new, $object); } + + if ($new instanceof IcingaHost && $customPropertyResolver) { + $customPropertyResolver->relinkObjectCustomProperties($new, $object); + } } } else { // No modification on the object, still, fields might have @@ -309,6 +341,10 @@ public function restoreType( if ($new instanceof IcingaObject) { $fieldResolver->relinkObjectFields($new, $object); } + + if ($new instanceof IcingaHost && $customPropertyResolver) { + $customPropertyResolver->relinkObjectCustomProperties($new, $object); + } } } @@ -322,6 +358,10 @@ public function restoreType( if ($new instanceof IcingaObject) { $fieldResolver->relinkObjectFields($new, $objects[$key]); } + + if ($new instanceof IcingaHost && $customPropertyResolver) { + $customPropertyResolver->relinkObjectCustomProperties($new, $objects[$key]); + } } } diff --git a/library/Director/DirectorObject/Automation/BasketSnapshotCustomPropertyResolver.php b/library/Director/DirectorObject/Automation/BasketSnapshotCustomPropertyResolver.php new file mode 100644 index 000000000..1c28b841a --- /dev/null +++ b/library/Director/DirectorObject/Automation/BasketSnapshotCustomPropertyResolver.php @@ -0,0 +1,258 @@ +objects = $objects; + $this->targetDb = $targetDb; + } + + /** + * @param Db $db + * + * @return DirectorProperty[] + * @throws \Icinga\Exception\NotFoundError + */ + public function loadCurrentProperties(Db $db): array + { + $properties = []; + foreach ($this->getRequiredUuids() as $uuid) { + $properties[$uuid] = DirectorProperty::loadWithUniqueId(Uuid::fromString($uuid), $db); + } + + return $properties; + } + + /** + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function storeNewProperties() + { + $this->targetProperties = null; // Clear Cache + foreach ($this->getTargetProperties() as $uuid => $property) { + if ($property->hasBeenModified()) { + $property->store(); + $this->uuidMap[$uuid] = Uuid::fromBytes($property->get('uuid'))->toString(); + } + + $modified = $this->restoreCustomPropertyItems($property); + if ($modified && ! isset($this->uuidMap[$uuid])) { + $this->uuidMap[$uuid] = Uuid::fromBytes($property->get('uuid'))->toString(); + } + } + } + + /** + * @param IcingaObject $new + * @param $object + * @throws \Icinga\Exception\NotFoundError + * @throws \Zend_Db_Adapter_Exception + */ + public function relinkObjectCustomProperties(IcingaObject $new, $object) + { + if (! $new->supportsFields() || ! isset($object->properties)) { + return; + } + + $customPropertyMap = $this->getUuidMap(); + $db = $this->targetDb->getDbAdapter(); + $objectUuid = DbUtil::quoteBinaryLegacy($new->get('uuid'), $db); + $type = $new->getShortTableName(); + + $table = $new->getTableName() . '_property'; + $objectKey = $type . '_uuid'; + $existingCustomProperties = []; + foreach ( + $db->fetchAll( + $db->select()->from($table)->where("$objectKey = ?", $objectUuid) + ) as $mapping + ) { + $existingCustomProperties[Uuid::fromBytes($mapping->property_uuid)->toString()] = $mapping; + } + + foreach ($object->properties as $property) { + $propertyUuid = $property->property_uuid; + if (! isset($customPropertyMap[$propertyUuid])) { + throw new InvalidArgumentException( + 'Basket Snapshot contains invalid custom property reference: ' . $propertyUuid + ); + } + + $uuid = $customPropertyMap[$propertyUuid]; + + if (isset($existingCustomProperties[$uuid])) { + unset($existingCustomProperties[$uuid]); + } else { + $db->insert($table, [ + $objectKey => $new->get('uuid'), + 'property_uuid' => Uuid::fromString($uuid)->getBytes(), + ]); + } + } + + $existingCustomPropertyUuids = array_keys($existingCustomProperties); + foreach ($existingCustomPropertyUuids as $idx => $uuid) { + $existingCustomPropertyUuids[$idx] = DbUtil::quoteBinaryLegacy($uuid, $db); + } + + if (! empty($existingCustomProperties)) { + $db->delete( + $table, + $db->quoteInto( + "$objectKey = $objectUuid AND property_uuid IN (?)", + $existingCustomPropertyUuids + ) + ); + } + } + + /** + * For diff purposes only, gives '(UNKNOWN)' for custom properties missing + * in our DB + * + * @param object $object + * @throws \Icinga\Exception\NotFoundError + */ + public function tweakTargetUuids($object) + { + $forward = $this->getUuidMap(); + $map = array_flip($forward); + if (isset($object->properties)) { + foreach ($object->properties as $property) { + $uuid = $property->property_uuid; + if (isset($map[$uuid])) { + $property->property_uuid = $map[$uuid]; + } else { + $property->property_uuid = "(UNKNOWN)"; + } + } + } + } + + protected function getRequiredUuids(): array + { + if ($this->requiredUuids === null) { + if (isset($this->objects['Property'])) { + $this->requiredUuids = array_keys($this->objects['Property']); + } else { + $uuids = []; + foreach ($this->objects as $typeName => $objects) { + foreach ($objects as $key => $object) { + if (isset($object->properties)) { + foreach ($object->properties as $property) { + $uuids[$property->property_uuid] = true; + } + } + } + } + + $this->requiredUuids = array_keys($uuids); + } + } + + return $this->requiredUuids; + } + + /** + * @param $type + * @return object[] + */ + protected function getObjectsByType($type): array + { + if (isset($this->objects->$type)) { + return (array) $this->objects->$type; + } else { + return []; + } + } + + /** + * @return DirectorProperty[] + * @throws \Icinga\Exception\NotFoundError + */ + protected function getTargetProperties(): array + { + if ($this->targetProperties === null) { + $this->calculateUuidMap(); + } + + return $this->targetProperties; + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + protected function getUuidMap(): array + { + if ($this->uuidMap === null) { + $this->calculateUuidMap(); + } + + return $this->uuidMap; + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + protected function calculateUuidMap() + { + $this->uuidMap = []; + $this->targetProperties = []; + foreach ($this->getObjectsByType('Property') as $uuid => $object) { + // Hint: import() doesn't store! + $new = DirectorProperty::import($object, $this->targetDb); + if ($new->hasBeenLoadedFromDb()) { + $newUuid = Uuid::fromBytes($new->get('uuid'))->toString(); + } else { + $newUuid = Uuid::uuid4()->toString(); + } + + $this->uuidMap[$uuid] = $newUuid; + $this->targetProperties[$uuid] = $new; + } + } + + private function restoreCustomPropertyItems(DirectorProperty $property): bool + { + $modified = false; + foreach ($property->getItems() as $item) { + if ($item->hasBeenModified()) { + $item->store(); + $modified = true; + } + + $modified = $modified || $this->restoreCustomPropertyItems($item); + } + + return $modified; + } +} diff --git a/library/Director/DirectorObject/Automation/ImportExport.php b/library/Director/DirectorObject/Automation/ImportExport.php index 1664f5d0e..eaa9c5ed5 100644 --- a/library/Director/DirectorObject/Automation/ImportExport.php +++ b/library/Director/DirectorObject/Automation/ImportExport.php @@ -8,6 +8,7 @@ use Icinga\Module\Director\Objects\DirectorDatafield; use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Objects\DirectorJob; +use Icinga\Module\Director\Objects\DirectorProperty; use Icinga\Module\Director\Objects\IcingaHostGroup; use Icinga\Module\Director\Objects\IcingaServiceGroup; use Icinga\Module\Director\Objects\IcingaServiceSet; @@ -82,6 +83,16 @@ public function serializeAllDataFields() return $res; } + public function serializeAllCustomProperties() + { + $res = []; + foreach (DirectorProperty::loadAll($this->connection) as $object) { + $res[] = $this->exporter->export($object); + } + + return $res; + } + public function serializeAllDataLists() { $res = []; diff --git a/library/Director/IcingaConfig/IcingaConfigHelper.php b/library/Director/IcingaConfig/IcingaConfigHelper.php index 0c314a3df..d7d537b72 100644 --- a/library/Director/IcingaConfig/IcingaConfigHelper.php +++ b/library/Director/IcingaConfig/IcingaConfigHelper.php @@ -375,13 +375,40 @@ public static function stringHasMacro($string, $macroName = null) /** * Hint: this isn't complete, but let's restrict ourselves right now * - * @param $name + * TODO: Not sure if this covers all cases. + * + * @param string $name + * @param ?array $whiteList + * * @return bool */ - public static function isValidMacroName($name) + public static function isValidMacroName(string $name, ?array $whiteList = null): bool { - return preg_match('/^[A-z_][A-z_.\d]+$/', $name) + $hasMacroPattern = preg_match('/^[A-z_][A-z_.\d]+$/', $name) && ! preg_match('/\.$/', $name); + + if (! $hasMacroPattern) { + return false; + } + + if ($whiteList === null || in_array($name, $whiteList)) { + return true; + } + + foreach ($whiteList as $pattern) { + if (str_contains($pattern, '*')) { + if ( + preg_match( + '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/', + $name + ) + ) { + return true; + } + } + } + + return false; } public static function renderStringWithVariables($string, array $whiteList = null) @@ -402,16 +429,15 @@ public static function renderStringWithVariables($string, array $whiteList = nul } else { // We got a macro $macroName = substr($string, $start + 1, $i - $start - 1); - if (static::isValidMacroName($macroName)) { - if ($whiteList === null || in_array($macroName, $whiteList)) { - if ($start > $offset) { - $parts[] = static::renderString( - substr($string, $offset, $start - $offset) - ); - } - $parts[] = $macroName; - $offset = $i + 1; + if (static::isValidMacroName($macroName, $whiteList)) { + if ($start > $offset) { + $parts[] = static::renderString( + substr($string, $offset, $start - $offset) + ); } + + $parts[] = $macroName; + $offset = $i + 1; } $start = false; diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php new file mode 100644 index 000000000..22b514141 --- /dev/null +++ b/library/Director/Objects/DirectorProperty.php @@ -0,0 +1,181 @@ + null, + 'key_name' => null, + 'parent_uuid' => null, + 'value_type' => null, + 'label' => null, + 'description' => null + ]; + + /** @var DirectorProperty[] */ + private $items = []; + + private $object; + + /** + * @throws NotFoundError + */ + public function export(): stdClass + { + $plain = (object) $this->getProperties(); + $uuid = $this->get('uuid'); + $parentUuid = $this->get('parent_uuid'); + if ($uuid) { + $uuid = Uuid::fromBytes($uuid); + $plain->uuid = $uuid->toString(); + $plain->items = $this->fetchChildren(); + } + + if ($parentUuid) { + $plain->parent_uuid = Uuid::fromBytes($parentUuid)->toString(); + } + + return $plain; + } + + private function fetchChildren(): array + { + $properties = []; + foreach ($this->getItems() as $property) { + $properties[$property->get('key_name')] = $property->export(); + } + + return $properties; + } + + public function getItems(): array + { + if ($this->items) { + return $this->items; + } + + $uuid = $this->get('uuid'); + if ($uuid === null) { + return []; + } + + $uuid = Uuid::fromBytes($uuid); + $query = $this->db->select() + ->from('director_property') + ->where( + 'parent_uuid = ?', + Db\DbUtil::quoteBinaryLegacy($uuid->getBytes(), $this->db) + ); + + foreach (DirectorProperty::loadAll($this->connection, $query) as $item) { + $item->items = $item->getItems(); + $this->items[] = $item; + } + + return $this->items; + } + + public static function fromDbRow($row, Db $connection) + { + $obj = static::create((array) $row, $connection); + $obj->loadedFromDb = true; + $obj->hasBeenModified = false; + $obj->modifiedProperties = []; + $obj->onLoadFromDb(); + + return $obj; + } + + + /** + * @throws NotFoundError + */ + public static function import(stdClass $plain, Db $db): static + { + $dba = $db->getDbAdapter(); + $uuid = $plain->uuid ?? null; + if ($uuid) { + $uuid = Uuid::fromString($uuid); + $items = $plain->items ?? []; + unset($plain->items); + $candidate = DirectorProperty::loadWithUniqueId($uuid, $db); + if ($candidate) { + assert($candidate instanceof DirectorProperty); + $candidate->setProperties((array) $plain); + $candidate->items = $candidate->importItems((array) $items, $db); + + return $candidate; + } + } + + $query = $dba->select()->from('director_property')->where('key_name = ?', $plain->key_name); + $candidates = DirectorProperty::loadAll($db, $query); + foreach ($candidates as $candidate) { + $export = $candidate->export(); + CompareBasketObject::normalize($export); + unset($export->uuid); + if (CompareBasketObject::equals($export, $plain)) { + return $candidate; + } + } + + return static::create((array) $plain, $db); + } + + private function importItems(array $items, Db $db): array + { + $itemCandidates = []; + foreach ($items as $key => $value) { + $itemUUid = $value->uuid ?? null; + if ($itemUUid) { + $itemUUid = Uuid::fromString($itemUUid); + $nestedItems = $value->items ?? []; + unset($value->items); + $itemCandidate = DirectorProperty::loadWithUniqueId($itemUUid, $db); + if ($itemCandidate) { + assert($itemCandidate instanceof DirectorProperty); + if (isset($value->parent_uuid)) { + $value->parent_uuid = Uuid::fromString($value->parent_uuid)->getBytes(); + } + + $itemCandidate->setProperties((array) $value); + $itemCandidate->items = $this->importItems($nestedItems, $db); + $itemCandidates[$key] = $itemCandidate; + } else { + if (isset($value->parent_uuid)) { + $value->parent_uuid = Uuid::fromString($value->parent_uuid)->getBytes(); + } + + $itemCandidates[$key] = DirectorProperty::import($value, $db); + } + } + } + + return $itemCandidates; + } + + protected function setObject(IcingaObject $object) + { + $this->object = $object; + } + + protected function getObject() + { + return $this->object; + } +} diff --git a/library/Director/Objects/IcingaDependency.php b/library/Director/Objects/IcingaDependency.php index 9f532b368..9f2397e7d 100644 --- a/library/Director/Objects/IcingaDependency.php +++ b/library/Director/Objects/IcingaDependency.php @@ -31,6 +31,7 @@ class IcingaDependency extends IcingaObject implements ExportInterface 'zone_id' => null, 'assign_filter' => null, 'parent_service_by_name' => null, + 'redundancy_group' => null, ]; protected $uuidColumn = 'uuid'; diff --git a/library/Director/Objects/IcingaHostVar.php b/library/Director/Objects/IcingaHostVar.php index 45656d5e7..4132902ee 100644 --- a/library/Director/Objects/IcingaHostVar.php +++ b/library/Director/Objects/IcingaHostVar.php @@ -13,6 +13,7 @@ class IcingaHostVar extends IcingaObject 'varname' => null, 'varvalue' => null, 'format' => null, + 'property_uuid' => null, ); public function onInsert() diff --git a/library/Director/Objects/IcingaObject.php b/library/Director/Objects/IcingaObject.php index 432730bc5..afaa7403d 100644 --- a/library/Director/Objects/IcingaObject.php +++ b/library/Director/Objects/IcingaObject.php @@ -23,6 +23,7 @@ use Icinga\Module\Director\Repository\IcingaTemplateRepository; use LogicException; use RuntimeException; +use stdClass; abstract class IcingaObject extends DbObject implements IcingaConfigRenderer { @@ -1313,6 +1314,11 @@ protected function resolve($what) $getOrigins = 'getOrigins' . $what; $blacklist = ['id', 'uuid', 'object_type', 'object_name', 'disabled']; + $linkedCustomProperties = []; + if ($what === 'Vars') { + $linkedCustomProperties = $this->fetchAllLinkedCustomProperties(); + } + foreach ($objects as $name => $object) { $origins = $object->$getOrigins(); @@ -1341,9 +1347,34 @@ protected function resolve($what) if (in_array($key, $blacklist)) { continue; } - $vals['_MERGED_']->$key = $value; - $vals['_INHERITED_']->$key = $value; - $vals['_ORIGINS_']->$key = $name; + + if ( + $what === 'Vars' + && array_key_exists($key, $linkedCustomProperties) && $linkedCustomProperties[$key]->value_type === 'dynamic-dictionary' + ) { + foreach ($value as $k => $v) { + if (! isset($vals['_MERGED_']->$key)) { + $vals['_MERGED_']->$key = new stdClass(); + } + + if (! isset($vals['_INHERITED_']->$key)) { + $vals['_INHERITED_']->$key = new stdClass(); + } + + $vals['_MERGED_']->$key->$k = $v; + $vals['_INHERITED_']->$key->$k = $v; + + if (! isset($vals['_ORIGINS_']->$key)) { + $vals['_ORIGINS_']->$key = $name; + } elseif ($vals['_ORIGINS_']->$key !== $name) { + $vals['_ORIGINS_']->$key .= ', ' . $name;; + } + } + } else { + $vals['_MERGED_']->$key = $value; + $vals['_INHERITED_']->$key = $value; + $vals['_ORIGINS_']->$key = $name; + } } } @@ -1352,7 +1383,20 @@ protected function resolve($what) continue; } - $vals['_MERGED_']->$key = $value; + if ( + $what === 'Vars' + && array_key_exists($key, $linkedCustomProperties) && $linkedCustomProperties[$key]->value_type === 'dynamic-dictionary' + ) { + foreach ($value as $k => $v) { + if (! isset($vals['_MERGED_']->$key)) { + $vals['_MERGED_']->$key = new stdClass(); + } + + $vals['_MERGED_']->$key->$k = $v; + } + } else { + $vals['_MERGED_']->$key = $value; + } } $this->storeResolvedCache($what, $vals); @@ -1447,6 +1491,41 @@ public function vars() return $this->vars; } + public function fetchAllLinkedCustomProperties(): array + { + if ($this->getShortTableName() !== 'host') { + return []; + } + + $templates = IcingaTemplateRepository::instanceByObject($this) + ->getTemplatesIndexedByNameFor($this, true); + if (empty($templates)) { + return []; + } + + $query = $this->db->select()->from( + ['dp' => 'director_property'], + ['dp.key_name', 'dp.uuid', 'dp.value_type'] + )->join( + ['iop' => 'icinga_host_property'], + 'dp.uuid = iop.property_uuid', + [] + )->join( + ['io' => 'icinga_host'], + 'iop.host_uuid = io.uuid', + [] + ) + ->where('io.object_name IN (?)', array_keys($templates)); + + $customProperties = []; + + foreach ($this->db->fetchAll($query) as $property) { + $customProperties[$property->key_name] = $property; + } + + return $customProperties; + } + /** * @return bool */ @@ -2219,7 +2298,7 @@ protected function renderLegacySuffix() protected function renderCustomVars() { if ($this->supportsCustomVars()) { - return $this->vars()->toConfigString($this->isApplyRule()); + return $this->vars()->toConfigString($this, $this->isApplyRule()); } return ''; diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index 9b51619b2..9ec84d770 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -14,6 +14,7 @@ use Icinga\Module\Director\Objects\Extension\FlappingSupport; use Icinga\Module\Director\Resolver\HostServiceBlacklist; use InvalidArgumentException; +use PDO; use RuntimeException; class IcingaService extends IcingaObject implements ExportInterface @@ -24,6 +25,8 @@ class IcingaService extends IcingaObject implements ExportInterface protected $uuidColumn = 'uuid'; + private $vars; + protected $defaultProperties = [ 'id' => null, 'uuid' => null, @@ -363,13 +366,25 @@ protected function renderObjectHeader() $name = ' ' . c::renderString($name); } - return sprintf( - "%s %s%s for (config in %s) {\n", - $this->getObjectTypeName(), - $this->getType(), - $name, - $this->get('apply_for') - ) . $extraName; + if ($this->isApplyRuleforDictionary(substr($this->get('apply_for') ?? '', strlen('host.vars.')))) { + $applyForConfig = sprintf( + "%s %s%s for (key => value in %s) {\n", + $this->getObjectTypeName(), + $this->getType(), + $name, + $this->get('apply_for') + ); + } else { + $applyForConfig = sprintf( + "%s %s%s for (value in %s) {\n", + $this->getObjectTypeName(), + $this->getType(), + $name, + $this->get('apply_for') + ) . $extraName; + } + + return $applyForConfig; } return parent::renderObjectHeader(); @@ -387,6 +402,28 @@ protected function getLegacyObjectKeyName() } } + protected function isApplyRuleforDictionary(string $applyFor): bool + { + $query = $this->db + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label' + ] + ) + ->join(['iop' => 'icinga_host_property'], 'dp.uuid = iop.property_uuid', []) + ->where("value_type LIKE '%dictionary'") + ->where("key_name = ?", $applyFor); + + $result = $this->db->fetchOne($query) ?? false; + + return $result !== false; + } + protected function rendersConditionalTemplate(): bool { return $this->getRenderingZone() === self::ALL_NON_GLOBAL_ZONES; @@ -624,6 +661,72 @@ public function createWhere() return $where; } + public function vars() + { + $vars = parent::vars(); + + if ($this->isApplyRule() && $vars) { + $applyFor = substr($this->get('apply_for') ?? '', strlen('host.vars.')); + $query = $this->db + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type' + ] + ) + ->join(['parent_dp' => 'director_property'], 'dp.parent_uuid = parent_dp.uuid', []) + ->where("parent_dp.value_type = 'dynamic-dictionary'") + ->where("parent_dp.key_name = ?", $applyFor); + + $result = $this->db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + + $whiteList = ['value', 'host.*']; + foreach ($result as $row) { + if (str_contains($row['key_name'], ' ')) { + continue; + } + + $variable = sprintf('value.%s', $row['key_name']); + if ($row['value_type'] === 'dynamic-dictionary') { + foreach ($this->fetchItemsForDictionary($row['uuid']) as $value) { + if (str_contains($value['key_name'], ' ')) { + continue; + } + + $whiteList[] = sprintf('%s.%s', $variable, $value['key_name']); + } + } + + $whiteList[] = $variable; + } + + $vars->setWhiteList($whiteList); + } + + return $vars; + } + + protected function fetchItemsForDictionary(string $uuid): array + { + $query = $this->db + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + ] + ) + ->join(['parent_dp' => 'director_property'], 'dp.parent_uuid = parent_dp.uuid', []) + ->where("dp.parent_uuid = ?", $uuid); + + return $this->db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + } + /** * TODO: Duplicate code, clean this up, split it into multiple methods diff --git a/library/Director/Resolver/TemplateTree.php b/library/Director/Resolver/TemplateTree.php index e1c1cfcfc..d1218d7f4 100644 --- a/library/Director/Resolver/TemplateTree.php +++ b/library/Director/Resolver/TemplateTree.php @@ -17,6 +17,8 @@ class TemplateTree protected $parents; + protected $parentsUuids; + protected $children; protected $rootNodes; @@ -148,6 +150,19 @@ public function getAncestorsFor(IcingaObject $object) } } + public function getAncestorsUuidsFor(IcingaObject $object) + { + if ( + $object->hasBeenModified() + && $object->gotImports() + && $object->imports()->hasBeenModified() + ) { + return $this->getAncestorsUuidsForUnstoredObject($object); + } else { + return $this->getParentsUuidsById($object->getProperty('id')); + } + } + protected function getAncestorsForUnstoredObject(IcingaObject $object) { $this->requireTree(); @@ -183,6 +198,36 @@ protected function getAncestorsForUnstoredObject(IcingaObject $object) return $ancestors; } + protected function getAncestorsUuidsForUnstoredObject(IcingaObject $object) + { + $this->requireTree(); + $ancestors = []; + foreach ($object->imports() as $import) { + $name = $import->get('uuid'); + if ($import->hasBeenLoadedFromDb()) { + $pid = (int) $import->get('id'); + } else { + if (! array_key_exists($name, $this->templateNameToId)) { + continue; + } + + $pid = $this->templateNameToId[$name]; + } + + $this->getAncestorsUuidsById($pid, $ancestors); + + $uuid = $import->get('uuid'); + // Hint: inheritance order matters + if (false !== ($key = array_search($uuid, $ancestors))) { + unset($ancestors[$key]); + } + + $ancestors[$pid] = $uuid; + } + + return $ancestors; + } + protected function requireObjectMaps() { if ($this->objectMaps === null) { @@ -287,6 +332,38 @@ public function getAncestorsById($id, &$ancestors = [], $path = []) return $ancestors; } + /** + * Get the ancestorUuids for the given object ID + * + * @param $id + * @param array $ancestors + * @param array $path + * + * @return array + */ + public function getAncestorsUuidsById($id, &$ancestors = [], $path = []) + { + $path[$id] = true; + foreach ($this->getParentsUuidsById($id) as $pid => $uuid) { + $this->assertNotInList($pid, $path); + $path[$pid] = true; + + $this->getAncestorsUuidsById($pid, $ancestors, $path); + unset($path[$pid]); + + // Hint: inheritance order matters + if (false !== ($key = array_search($uuid, $ancestors))) { + unset($ancestors[$key]); + } + + $ancestors[$pid] = $uuid; + } + + unset($path[$id]); + + return $ancestors; + } + public function getChildrenFor(IcingaObject $object) { // can not use hasBeenLoadedFromDb() when in onStore() @@ -386,6 +463,8 @@ protected function prepareTree() $rootNodes = []; $children = []; $names = []; + $parentsUuids = []; + $uuids = []; foreach ($templates as $row) { $id = (int) $row->id; $pid = (int) $row->parent_id; @@ -404,6 +483,7 @@ protected function prepareTree() $names[$pid] = $row->parent_name; $parents[$id][$pid] = $row->parent_name; + $parentsUuids[$id][$pid] = $row->parent_uuid; if (! array_key_exists($pid, $children)) { $children[$pid] = []; @@ -412,7 +492,8 @@ protected function prepareTree() $children[$pid][$id] = $row->name; } - $this->parents = $parents; + $this->parents = $parents; + $this->parentsUuids = $parentsUuids; $this->children = $children; $this->rootNodes = $rootNodes; $this->names = $names; @@ -458,6 +539,7 @@ public function fetchTemplates() 'object_type' => 'o.object_type', 'parent_id' => 'p.id', 'parent_name' => 'p.object_name', + 'parent_uuid' => 'p.uuid' ] )->joinLeft( ['i' => $table . '_inheritance'], diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index 7329be33f..a2a3c461b 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -8,12 +8,15 @@ use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Core\CoreApi; use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\DirectorObject\Lookup\ServiceFinder; use Icinga\Module\Director\Exception\DuplicateKeyException; use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Resolver\OverrideHelper; use InvalidArgumentException; +use PDO; +use Ramsey\Uuid\Uuid; use RuntimeException; class IcingaObjectHandler extends RequestHandler @@ -91,11 +94,53 @@ protected function processApiRequest() $this->sendJsonError($e); } - if ($this->request->getActionName() !== 'index') { + if ($this->request->getActionName() !== 'index' && $this->request->getActionName() !== 'variables') { throw new NotFoundError('Not found'); } } + /** + * Get the custom properties linked to the given object. + * + * @param IcingaObject $object + * + * @return array + */ + public function getCustomProperties(IcingaObject $object): array + { + if ($object->get('uuid') === null) { + return []; + } + + $type = $object->getShortTableName(); + $db = $object->getConnection(); + $ids = $object->listAncestorIds(); + $ids[] = $object->get('id'); + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label' + ] + ) + ->join(['iop' => "icinga_$type" . '_property'], 'dp.uuid = iop.property_uuid', []) + ->join(['io' => "icinga_$type"], 'io.uuid = iop.' . $type . '_uuid', []) + ->where('io.id IN (?)', $ids) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->order('key_name'); + + $result = []; + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + $result[$row['key_name']] = $row; + } + + return $result; + } + protected function handleApiRequest() { $request = $this->request; @@ -121,36 +166,94 @@ protected function handleApiRequest() $object = $this->requireObject(); $object->delete(); $this->sendJson($object->toPlainObject(false, true)); - break; + break; case 'POST': case 'PUT': $data = (array) $this->requireJsonBody(); $params = $this->request->getUrl()->getParams(); $allowsOverrides = $params->get('allowOverrides'); $type = $this->getType(); - if ($object = $this->loadOptionalObject()) { - if ($request->getMethod() === 'POST') { - $object->setProperties($data); + $object = $this->loadOptionalObject(); + $actionName = $this->request->getActionName(); + + $overRiddenCustomVars = []; + if ($actionName === 'variables') { + $overRiddenCustomVars = $data; + } else { + if ($type === 'host') { + // Extract custom vars from the data + foreach ($data as $key => $value) { + if ($key === 'vars') { + $overRiddenCustomVars = ['vars' => (array) $value]; + + unset($data['vars']); + } + + if (substr($key, 0, 5) === 'vars.') { + $overRiddenCustomVars['vars'][substr($key, 5)] = $value; + + unset($data[$key]); + } + } + } + + if ($object) { + if ($request->getMethod() === 'POST') { + $object->setProperties($data); + } else { + $data = array_merge([ + 'object_type' => $object->get('object_type'), + 'object_name' => $object->getObjectName() + ], $data); + $object->replaceWith(IcingaObject::createByType($type, $data, $db)); + } + + $this->persistChanges($object); + } elseif ($allowsOverrides && $type === 'service') { + if ($request->getMethod() === 'PUT') { + throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); + } + + $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data); } else { - $data = array_merge([ - 'object_type' => $object->get('object_type'), - 'object_name' => $object->getObjectName() - ], $data); - $object->replaceWith(IcingaObject::createByType($type, $data, $db)); + $object = IcingaObject::createByType($type, $data, $db); + $this->persistChanges($object); } - $this->persistChanges($object); - $this->sendJson($object->toPlainObject(false, true)); - } elseif ($allowsOverrides && $type === 'service') { - if ($request->getMethod() === 'PUT') { - throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); + } + + if ($type !== 'service' && $overRiddenCustomVars) { + $customProperties = $this->getCustomProperties($object); + if (! empty($overRiddenCustomVars)) { + $objectVars = $object->vars(); + foreach ($overRiddenCustomVars as $key => $value) { + if (! isset($customProperties[$key])) { + if ($object->isTemplate()) { + $errMsg = sprintf( + "The custom property %s should be first added to the template", + $key + ); + } else { + $errMsg = sprintf( + "The custom property %s should be first added to one of the imported templates" + . " for this object", + $key + ); + } + + throw new NotFoundError($errMsg); + } + + $objectVars->set($key, $value); + $objectVars->registerVarUuid($key, Uuid::fromBytes($customProperties[$key]['uuid'])); + } } - $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data); - } else { - $object = IcingaObject::createByType($type, $data, $db); + $this->persistChanges($object); - $this->sendJson($object->toPlainObject(false, true)); } + + $this->sendJson($object->toPlainObject(false, true)); + break; case 'GET': diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index 88e38a45b..b48060e19 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -10,7 +10,6 @@ use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; use Icinga\Module\Director\Db\Branch\Branch; use Icinga\Module\Director\Db\Branch\BranchedObject; -use Icinga\Module\Director\Db\Branch\BranchSupport; use Icinga\Module\Director\Db\Branch\UuidLookup; use Icinga\Module\Director\Deployment\DeploymentInfo; use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; @@ -18,6 +17,7 @@ use Icinga\Module\Director\Forms\DeploymentLinkForm; use Icinga\Module\Director\Forms\IcingaCloneObjectForm; use Icinga\Module\Director\Forms\IcingaObjectFieldForm; +use Icinga\Module\Director\Forms\ObjectPropertyForm; use Icinga\Module\Director\Objects\IcingaCommand; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Objects\IcingaObjectGroup; @@ -34,7 +34,9 @@ use Icinga\Module\Director\Web\Tabs\ObjectTabs; use Icinga\Module\Director\Web\Widget\BranchedObjectHint; use gipfl\IcingaWeb2\Link; +use Icinga\Web\Notification; use ipl\Html\Html; +use ipl\Web\Url; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; @@ -101,6 +103,10 @@ protected function initializeRestApi() protected function initializeWebRequest() { + if ($this->getRequest()->getActionName() === 'add-property') { + return; + } + if ($this->getRequest()->getActionName() === 'add') { $this->addSingleTab( sprintf($this->translate('Add %s'), ucfirst($this->getType())), @@ -268,6 +274,30 @@ public function fieldsAction() } } + public function addPropertyAction() + { + $this->assertPermission('director/admin'); + $object = $this->requireObject(); + $this->view->title = sprintf($this->translate('Add Custom Property: %s'), $this->object->getObjectName()); + $objectUuid = $this->object->get('uuid'); + + $form = (new ObjectPropertyForm($this->db(), $object)) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(ObjectPropertyForm::ON_SUCCESS, function (ObjectPropertyForm $form) use ($objectUuid) { + Notification::success(sprintf( + sprintf($this->translate('Property %s has successfully been added'), $form->getPropertyName()) + )); + + $this->redirectNow(Url::fromPath( + 'director/' . $this->getType() . '/variables', + ['uuid' => UUid::fromBytes($objectUuid)->toString()] + )); + }) + ->handleRequest($this->getServerRequest()); + + $this->content()->add($form); + } + protected function addFieldsFormAndTable($object, $type) { $form = IcingaObjectFieldForm::load() diff --git a/library/Director/Web/Form/DirectorObjectForm.php b/library/Director/Web/Form/DirectorObjectForm.php index 62e539ce8..a0bdce8ea 100644 --- a/library/Director/Web/Form/DirectorObjectForm.php +++ b/library/Director/Web/Form/DirectorObjectForm.php @@ -15,6 +15,7 @@ use Icinga\Module\Director\Hook\IcingaObjectFormHook; use Icinga\Module\Director\IcingaConfig\StateFilterSet; use Icinga\Module\Director\IcingaConfig\TypeFilterSet; +use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaTemplateChoice; use Icinga\Module\Director\Objects\IcingaCommand; use Icinga\Module\Director\Objects\IcingaObject; @@ -820,14 +821,24 @@ protected function onRequest() if ($this->object !== null) { $this->setDefaultsFromObject($this->object); } - $this->prepareFields($this->object()); + + $isHost = $this->object instanceof IcingaHost; + + if (! $isHost) { + $this->prepareFields($this->object()); + } + IcingaObjectFormHook::callOnSetup($this); if ($this->hasBeenSent()) { $this->handlePost(); } + try { $this->loadInheritedProperties(); - $this->addFields(); + if (! $isHost) { + $this->addFields(); + } + $this->callOnRequestCallables(); } catch (Exception $e) { $this->addUniqueException($e); @@ -848,7 +859,7 @@ protected function handlePost() $this->populate($post); $values = $this->getValues(); - if ($object instanceof IcingaObject) { + if ($object instanceof IcingaObject && $object->getShortTableName() !== 'host') { $this->setCustomVarValues($post); } diff --git a/library/Director/Web/Form/Element/ArrayElement.php b/library/Director/Web/Form/Element/ArrayElement.php new file mode 100644 index 000000000..277c333b4 --- /dev/null +++ b/library/Director/Web/Form/Element/ArrayElement.php @@ -0,0 +1,71 @@ + 'array-input']; + + public function setPlaceHolder(string $placeHolder): static + { + $this->placeHolder = $placeHolder; + + return $this; + } + + protected function assemble() + { + parent::assemble(); + + $valuePlaceHolder = $this->translate('Separate multiple values by comma.'); + if ($this->placeHolder) { + $valuePlaceHolder = $this->placeHolder . '. ' . $valuePlaceHolder; + } + + $this->getElement('value') + ->getAttributes() + ->registerAttributeCallback('placeholder', function () use ($valuePlaceHolder) { + return $valuePlaceHolder; + }); + } + + public function getValue($name = null, $default = null) + { + if ($name !== null) { + return parent::getValue($name, $default); + } + + $terms = []; + foreach ($this->getTerms() as $term) { + $terms[] = $term->render(','); + } + + return $terms; + } + + public function setValue($value) + { + if (is_array($value) && isset($value['value'])) { + $separatedTerms = $value['value'] ?? ''; + parent::setValue($value); + } elseif (is_array($value)) { + $separatedTerms = implode(',', $value); + } else { + $separatedTerms = $value; + } + + $terms = []; + foreach ($this->parseValue((string) $separatedTerms) as $term) { + $terms[] = new RegisteredTerm($term); + } + + return $this->setTerms(...$terms); + } +} diff --git a/library/Director/Web/Form/Element/IplBoolean.php b/library/Director/Web/Form/Element/IplBoolean.php new file mode 100644 index 000000000..474644c2c --- /dev/null +++ b/library/Director/Web/Form/Element/IplBoolean.php @@ -0,0 +1,64 @@ + $this->translate('Yes'), + 'n' => $this->translate('No'), + ]; + if (! $this->isRequired()) { + $options = [ + null => $this->translate('- Please choose -'), + ] + $options; + } + + $this->setOptions($options); + } + + public function setValue($value) + { + if ($value === 'y' || $value === true) { + return parent::setValue('y'); + } elseif ($value === 'n' || $value === false) { + return parent::setValue('n'); + } + + // Hint: this will fail + return parent::setValue($value); + } + + public function getValue() + { + if ($this->value === 'y') { + return true; + } elseif ($this->value === 'n') { + return false; + } + + return $this->value; + } + + protected function isSelectedOption($optionValue): bool + { + $optionValue = match ($optionValue) { + 'y' => true, + 'n' => false, + default => null + }; + + return parent::isSelectedOption( + $optionValue + ); + } +} diff --git a/library/Director/Web/Tabs/ObjectTabs.php b/library/Director/Web/Tabs/ObjectTabs.php index e9142367a..dac713d22 100644 --- a/library/Director/Web/Tabs/ObjectTabs.php +++ b/library/Director/Web/Tabs/ObjectTabs.php @@ -93,7 +93,15 @@ protected function addTabsForExistingObject() )); } - if ($auth->hasPermission(Permission::ADMIN) && $this->hasFields()) { + if ($this->object->getShortTableName() === 'host') { + if ($auth->hasPermission(Permission::ADMIN)) { + $this->add('variables', array( + 'url' => sprintf('director/%s/variables', $type), + 'urlParams' => $params, + 'label' => $this->translate('Custom Variables') + )); + } + } elseif ($auth->hasPermission(Permission::ADMIN) && $this->hasFields()) { $this->add('fields', array( 'url' => sprintf('director/%s/fields', $type), 'urlParams' => $params, diff --git a/library/Director/Web/Widget/CustomVarObjectList.php b/library/Director/Web/Widget/CustomVarObjectList.php new file mode 100644 index 000000000..c794f8b2f --- /dev/null +++ b/library/Director/Web/Widget/CustomVarObjectList.php @@ -0,0 +1,62 @@ +setItemLayoutClass(MinimalItemLayout::class); + } + + public function getDetailActionsDisabled(): bool + { + return $this->actionDisabled; + } + + public function setDetailActionsDisabled(bool $actionDisabled = true): static + { + $this->actionDisabled = $actionDisabled; + + return $this; + } + + protected function createListItem(object $data): ListItem + { + parent::createListItem($data); + + $item = parent::createListItem($data); + if ($this->getDetailActionsDisabled()) { + return $item; + } + + $url = Url::fromPath('director/host/variables'); + $filter = Filter::equal('name', $data->name); + $this->getAttributes()->add('class', 'action-list'); + $this->getAttributes() + ->registerAttributeCallback('data-icinga-detail-url', function () use ($url) { + return $this->getDetailActionsDisabled() ? null : (string) $url; + }); + + $item->getAttributes() + ->registerAttributeCallback('data-action-item', function () { + return ! $this->getDetailActionsDisabled(); + }) + ->registerAttributeCallback('data-icinga-detail-filter', function () use ($filter) { + return $this->getDetailActionsDisabled() ? null : QueryString::render($filter); + }); + + return $item; + } +} diff --git a/library/Director/Web/Widget/CustomVarRenderer.php b/library/Director/Web/Widget/CustomVarRenderer.php new file mode 100644 index 000000000..3803c9449 --- /dev/null +++ b/library/Director/Web/Widget/CustomVarRenderer.php @@ -0,0 +1,58 @@ +addHtml(Html::sprintf( + '%s', + $this->createSubject($item, $layout), + )); + } + + protected function createSubject($item, string $layout): Link + { + return new Link( + $item->name, + Url::fromPath('director/host/variables', ['name' => $item->name])->getAbsoluteUrl(), + ['class' => ['subject', 'object-link']] + ); + } + + public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void + { + $info->addHtml(new HtmlElement('span', null, new Text($item->type))); + } + + public function assemble($item, string $name, HtmlDocument $element, string $layout): bool + { + return false; + } +} diff --git a/library/Director/Web/Widget/ObjectPropertyTable.php b/library/Director/Web/Widget/ObjectPropertyTable.php new file mode 100644 index 000000000..652518071 --- /dev/null +++ b/library/Director/Web/Widget/ObjectPropertyTable.php @@ -0,0 +1,57 @@ + 'common-table table-row-selectable object-property-table' + ]; + + public function __construct( + protected UuidInterface $objectUuid, + protected array $properties + ) { + } + + protected function assemble() + { + $this->add(static::tr([ + static::th([HtmlElement::create('p', null, $this->translate('Key'))])->setSeparator(' '), + static::th([HtmlElement::create('p', null, $this->translate('Label'))])->setSeparator(' '), + static::th([HtmlElement::create('p', null, $this->translate('Type'))]), + static::th([HtmlElement::create('p', null, $this->translate('Mandatory'))]) + ])); + foreach ($this->properties as $property) { + $objectPropertyLink = new Link( + $property->key_name, + Url::fromPath( + 'director/host/properties', + [ + 'uuid' => $this->objectUuid->toString(), + 'property_uuid' => Uuid::fromBytes($property->uuid)->toString() + ] + ), + ['target' => '_blank'] + ); + + $this->add(static::tr([ + static::td([HtmlElement::create('p', null, $objectPropertyLink)])->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->label)])->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->value_type)])->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->required)]) + ])); + } + } +} diff --git a/library/Director/Web/Widget/PropertyTable.php b/library/Director/Web/Widget/PropertyTable.php new file mode 100644 index 000000000..4f41bfccf --- /dev/null +++ b/library/Director/Web/Widget/PropertyTable.php @@ -0,0 +1,55 @@ + 'common-table table-row-selectable property-table', + 'data-base-target' => '_next', + ]; + + public function __construct( + protected array $properties, + protected bool $isFieldsTable = false + ) { + } + + protected function assemble() + { + foreach ($this->properties as $property) { + $url = Url::fromPath( + 'director/property', + ['uuid' => Uuid::fromBytes($property->uuid)->toString()] + ); + + if (isset($property->parent_uuid)) { + $url->addParams(['parent_uuid' => Uuid::fromBytes($property->parent_uuid)->toString()]); + } + + $columns = [ + static::td([HtmlElement::create('strong', null, new Link($property->key_name, $url))]) + ->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->label)])->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->value_type)]), + ]; + + if (isset($property->used_count) && $property->used_count > 0) { + $columns[] = static::td([HtmlElement::create('p', null, $this->translate('In use'))]); + } else { + $columns[] = static::td([HtmlElement::create('p', null, $this->translate('Not in use'))]); + } + + $this->addHtml(static::tr($columns)); + } + } +} diff --git a/public/css/action-list.less b/public/css/action-list.less new file mode 100644 index 000000000..5bb08f4d1 --- /dev/null +++ b/public/css/action-list.less @@ -0,0 +1,14 @@ +.action-list { + [data-action-item]:hover { + background-color: @tr-hover-color; + cursor: pointer; + } + + [data-action-item].active { + background-color: @tr-active-color; + } + + &[data-icinga-multiselect-url] * { + user-select: none; + } +} diff --git a/public/css/custom-properties-form.less b/public/css/custom-properties-form.less new file mode 100644 index 000000000..9ffb87040 --- /dev/null +++ b/public/css/custom-properties-form.less @@ -0,0 +1,110 @@ +// Style +.custom-properties-form { + .control-group:has(> fieldset) { + .dictionary, + .nested-dictionary, + .nested-dictionary-item { + &.no-border { + border: none; + } + + border: 1px solid @gray-light; + border-radius: 1em; + .remove-button { + width: fit-content; + border: none; + background: none; + color: @color-critical; + &:hover { + background-color: @color-critical; + color: @text-color-inverted; + } + } + } + + .nested-dictionary-item { + > legend { + font-size: 1em; + color: @text-color-light; + font-weight: normal; + background-color: @gray-lighter; + width: 100%; + &::before { + // icon: down + font-family: 'ifont'; + content: '\e81d'; + } + } + + &.collapsed { + legend::before { + // icon: right + content: '\e820'; + } + } + } + } +} + +// Layout +.custom-properties-form { + .control-group:has(> fieldset) { + position: relative; + padding-right: 2em; + + .dictionary, + .nested-dictionary, + .nested-dictionary-item { + padding: 0 0.5em; + .remove-button { + position: absolute; + top: 0; + right: 0; + margin-top: 0.1em; + margin-right: 3.5em; + justify-content: center; + } + } + + .nested-dictionary { + .control-group.form-controls:last-of-type { + margin: 1em; + } + + .inherited-value { + margin-right: 3em; + } + + .empty-state-bar { + margin-right: 2em; + } + } + + .nested-dictionary-item.collapsed { + .remove-button { + margin-right: 3em; + } + } + + .nested-dictionary-item { + > .control-group:first-of-type { + margin-top: 1em; + } + + > legend { + padding: 0.25em 0.5em; + margin-top: 0; + margin-bottom: 0; + + &::before { + margin-right: 0.5em; + } + } + + &.collapsed { + border: none; + padding: 0; + } + } + } +} \ No newline at end of file diff --git a/public/css/item-list.less b/public/css/item-list.less new file mode 100644 index 000000000..ab5580bd4 --- /dev/null +++ b/public/css/item-list.less @@ -0,0 +1,73 @@ +// Style + +.item-list { + .load-more:hover, + .page-separator:hover { + background: none; + } + + > .load-more a { + .rounded-corners(.25em); + background: @low-sat-blue; + text-align: center; + + &:hover { + opacity: .8; + text-decoration: none; + } + } + + > .page-separator:after { + content: ""; + display: block; + width: 100%; + height: 1px; + background: @gray; + align-self: center; + margin-left: .25em; + } + + > .page-separator a { + color: @gray; + font-weight: bold; + + &:hover { + text-decoration: none; + } + } + + > .page-separator + .list-item .main { + border-top: none; + } +} + +// Layout + +.item-list .load-more { + display: flex; + + a { + flex: 1; + margin: 1.5em 0; + padding: .5em 0; + } +} + +.item-list { + // Not sure what this is for. Maybe user content? (Markdown) But why in the title?? + .default-item-layout .title { + p { + margin: 0; + } + } + + .minimal-item-layout .title { + p { + display: inline; + + & + p { + margin-left: .417em; + } + } + } +} diff --git a/public/css/item/item-layout.less b/public/css/item/item-layout.less new file mode 100644 index 000000000..274335652 --- /dev/null +++ b/public/css/item/item-layout.less @@ -0,0 +1,6 @@ +// Style +.item-layout { + .object-link { + color: @text-color; + } +} diff --git a/public/css/module.less b/public/css/module.less index 51c4ec25e..1f79d12c7 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -17,6 +17,19 @@ table.common-table td { } } +.nested-key-list, .nested-key-list ul { + list-style-type: none; + padding: 0; + margin: 0; + + li { + display: grid; + grid-template-columns: repeat(3, 1fr); + justify-items: center; + width: 100%; + } +} + #layout.minimal-layout table.common-table td { padding-top: 0.5em; padding-bottom: 0.5em; @@ -490,6 +503,10 @@ form.director-form .host-group-links { text-decoration: line-through; } +details { + width: 100%; +} + // TODO: figure out whether form.editor and filter-related CSS is still required div.filter > form.search, div.filter > a { // Duplicated by quicksearch diff --git a/public/css/property-form.less b/public/css/property-form.less new file mode 100644 index 000000000..247022baf --- /dev/null +++ b/public/css/property-form.less @@ -0,0 +1,3 @@ +.common-table.property-table { + max-width: 100%; +} \ No newline at end of file diff --git a/public/css/property-table.less b/public/css/property-table.less new file mode 100644 index 000000000..1e425fba9 --- /dev/null +++ b/public/css/property-table.less @@ -0,0 +1,5 @@ +.property-form { + .btn-remove { + .button(@body-bg-color, @color-critical, @color-critical-accentuated); + } +} \ No newline at end of file diff --git a/public/js/module.js b/public/js/module.js index 07fe265dc..2d60fc35a 100644 --- a/public/js/module.js +++ b/public/js/module.js @@ -637,6 +637,10 @@ toggleFieldset: function (ev) { ev.stopPropagation(); var $fieldset = $(ev.currentTarget).closest('fieldset'); + if (! $fieldset.closest('form').hasClass('director-form')) { + return; + } + $fieldset.toggleClass('collapsed'); this.fixFieldsetInfo($fieldset); this.openedFieldsets[$fieldset.attr('id')] = ! $fieldset.hasClass('collapsed'); @@ -731,7 +735,7 @@ url = $container.data('icingaUrl'); $actions = $('.main-actions', $('#col1')); } - if (! $actions.length) { + if ($actions) { return; } @@ -786,6 +790,10 @@ restoreFieldsets: function (idx, form) { var $form = $(form); + if (! $form.hasClass('director-form')) { + return; + } + var self = this; var $sets = $('fieldset', $form); @@ -814,7 +822,7 @@ }, fixFieldsetInfo: function ($fieldset) { - if ($fieldset.hasClass('collapsed')) { + if ($fieldset.hasClass('collapsed') && $fieldset.closest('form').hasClass('director-form')) { if ($fieldset.find('legend span.element-count').length === 0) { var cnt = $fieldset.find('dt, li').not('.extensible-set li').length; if (cnt > 0) { diff --git a/schema/mysql-migrations/upgrade_190.sql b/schema/mysql-migrations/upgrade_190.sql new file mode 100644 index 000000000..c9c5bfc85 --- /dev/null +++ b/schema/mysql-migrations/upgrade_190.sql @@ -0,0 +1,34 @@ +CREATE TABLE director_property ( + uuid binary(16) NOT NULL, + parent_uuid binary(16) NULL DEFAULT NULL, + key_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + label varchar(255) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, + value_type enum('string', 'number', 'bool', 'fixed-array', 'fixed-dictionary', 'dynamic-array', 'dynamic-dictionary') COLLATE utf8mb4_unicode_ci NOT NULL, + description text, + instantiable enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (uuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_host_property ( + host_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (host_uuid, property_uuid), + CONSTRAINT icinga_host_property_host + FOREIGN KEY host(host_uuid) + REFERENCES icinga_host (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +ALTER TABLE icinga_host_var + ADD COLUMN property_uuid binary(16); + +INSERT INTO director_schema_migration +(schema_version, migration_time) +VALUES (190, NOW()); diff --git a/schema/mysql-migrations/upgrade_191.sql b/schema/mysql-migrations/upgrade_191.sql new file mode 100644 index 000000000..c5ce33ad4 --- /dev/null +++ b/schema/mysql-migrations/upgrade_191.sql @@ -0,0 +1,43 @@ +ALTER TABLE director_property + MODIFY COLUMN value_type ENUM( + 'string', + 'number', + 'bool', + 'array', + 'dict', + 'fixed-array', + 'fixed-dictionary', + 'dynamic-array', + 'dynamic-dictionary' + ) NOT NULL; + +UPDATE director_property +SET value_type = CASE + WHEN value_type = 'array' AND instantiable = 'n' THEN 'fixed-array' + WHEN value_type = 'array' AND instantiable = 'y' THEN 'dynamic-array' + WHEN value_type = 'dict' AND instantiable = 'n' THEN 'fixed-dictionary' + WHEN value_type = 'dict' AND instantiable = 'y' THEN 'dynamic-dictionary' + ELSE value_type + END; + +ALTER TABLE director_property + MODIFY COLUMN value_type ENUM( + 'string', + 'number', + 'bool', + 'fixed-array', + 'fixed-dictionary', + 'dynamic-array', + 'dynamic-dictionary' + ) NOT NULL; + +ALTER TABLE icinga_host_var + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; + +ALTER TABLE director_property + DROP COLUMN instantiable, + ADD COLUMN description TEXT DEFAULT NULL; + +INSERT INTO director_schema_migration +(schema_version, migration_time) +VALUES (191, NOW()); \ No newline at end of file diff --git a/schema/mysql.sql b/schema/mysql.sql index 8052ecdea..aaae7d0cc 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -651,20 +651,51 @@ CREATE TABLE icinga_host_field ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE director_property ( + uuid binary(16) NOT NULL, + parent_uuid binary(16) NULL DEFAULT NULL, + key_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + label varchar(255) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, + description text DEFAULT NULL, + value_type enum('string', 'number', 'bool', 'fixed-array', 'dynamic-array', 'fixed-dictionary', 'dynamic-dictionary') COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (uuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE icinga_host_property ( + host_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (host_uuid, property_uuid), + CONSTRAINT icinga_host_property_host + FOREIGN KEY host(host_uuid) + REFERENCES icinga_host (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + CREATE TABLE icinga_host_var ( host_id INT(10) UNSIGNED NOT NULL, varname VARCHAR(255) NOT NULL COLLATE utf8_bin, varvalue TEXT DEFAULT NULL, format enum ('string', 'json', 'expression'), -- immer string vorerst checksum VARBINARY(20) DEFAULT NULL, + property_uuid BINARY(16) DEFAULT NULL, PRIMARY KEY (host_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), CONSTRAINT icinga_host_var_host - FOREIGN KEY host (host_id) - REFERENCES icinga_host (id) - ON DELETE CASCADE - ON UPDATE CASCADE + FOREIGN KEY host (host_id) + REFERENCES icinga_host (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_var_property_uuid + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ALTER TABLE icinga_host_template_choice @@ -2446,4 +2477,4 @@ CREATE TABLE branched_icinga_dependency ( INSERT INTO director_schema_migration (schema_version, migration_time) - VALUES (189, NOW()); + VALUES (191, NOW()); diff --git a/test/php/library/Director/Objects/IcingaServiceTest.php b/test/php/library/Director/Objects/IcingaServiceTest.php index 3005349e3..8ac10b143 100644 --- a/test/php/library/Director/Objects/IcingaServiceTest.php +++ b/test/php/library/Director/Objects/IcingaServiceTest.php @@ -218,7 +218,7 @@ public function testApplyForRendersInVariousModes() (string) $service ); - $service->object_name = '___TEST$config$___service $host.var.bla$'; + $service->object_name = '___TEST$value$___service $host.var.bla$'; $this->assertEquals( $this->loadRendered('service6'), (string) $service diff --git a/test/php/library/Director/Objects/rendered/service5.out b/test/php/library/Director/Objects/rendered/service5.out index b05e63011..b186d5ab4 100644 --- a/test/php/library/Director/Objects/rendered/service5.out +++ b/test/php/library/Director/Objects/rendered/service5.out @@ -1,4 +1,4 @@ -apply Service "___TEST___service" for (config in host.vars.test1) { +apply Service "___TEST___service" for (value in host.vars.test1) { display_name = "Whatever service" assign where host.vars.env == "test" vars.test1 = "string" diff --git a/test/php/library/Director/Objects/rendered/service6.out b/test/php/library/Director/Objects/rendered/service6.out index fdca11c4b..e06896693 100644 --- a/test/php/library/Director/Objects/rendered/service6.out +++ b/test/php/library/Director/Objects/rendered/service6.out @@ -1,5 +1,5 @@ -apply Service for (config in host.vars.test1) { - name = "___TEST" + config + "___service " + host.var.bla +apply Service for (value in host.vars.test1) { + name = "___TEST" + value + "___service " + host.var.bla display_name = "Whatever service" assign where host.vars.env == "test" vars.test1 = "string" diff --git a/test/php/library/Director/Objects/rendered/service7.out b/test/php/library/Director/Objects/rendered/service7.out index c125cccec..c447dcb7b 100644 --- a/test/php/library/Director/Objects/rendered/service7.out +++ b/test/php/library/Director/Objects/rendered/service7.out @@ -1,4 +1,4 @@ -apply Service for (config in host.vars.test1) { +apply Service for (value in host.vars.test1) { display_name = "Whatever service" assign where host.vars.env == "test" vars.test1 = "string"