diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index aa5e2932d..efd40c7f6 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3'] + php: ['8.2', '8.3', '8.4', '8.5'] os: ['ubuntu-latest'] steps: @@ -48,7 +48,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.2', '8.3'] + php: ['8.2', '8.3', '8.4', '8.5'] os: ['ubuntu-latest'] services: 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..ae8e83123 100644 --- a/application/clicommands/HostsCommand.php +++ b/application/clicommands/HostsCommand.php @@ -3,6 +3,10 @@ namespace Icinga\Module\Director\Clicommands; use Icinga\Module\Director\Cli\ObjectsCommand; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use PDO; +use Ramsey\Uuid\Uuid; /** * Manage Icinga Hosts @@ -11,4 +15,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[] = $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/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php new file mode 100644 index 000000000..5b1f9e98a --- /dev/null +++ b/application/clicommands/MigrateCommand.php @@ -0,0 +1,454 @@ +db(); + $customPropertiesToMigrate = $this->prepareCustomProperties(); + $dryRun = $this->params->shift('dry-run') ?? false; + // Dry run summary + if ($dryRun) { + $this->checkMigrateableDatafieldTypes(); + $this->checkProtectedDatafields(); + $this->checkDatafieldsWithCategory(); + $this->checkUnmigrateableDatafieldTypes(); + $this->checkDatafieldsWithDuplicateNames(); + printf( + "Number of datafields that can not be migrated as the custom properties with the same name already" + . " exists: %d\n", + count($this->existingCustomProperties) + ); + } + + echo "Migrating Data fields\n"; + + foreach ($this->existingCustomProperties as $varname) { + unset($customPropertiesToMigrate[$varname]); + + if ($this->isVerbose) { + echo "[-] Skipping migrating datafield '$varname' as a custom property" + . " with the same name already exists\n"; + } + } + + $typeOffset = strlen("Icinga\Module\Director\DataType\DataType"); + if ($this->isVerbose) { + foreach ($this->getDatafieldsWithUnsupportedValuetype() as $varname => $datatype) { + $dataType = substr($datatype, $typeOffset); + + echo "[-] Skipping migrating datafield '$varname' as it has an unsupported datatype '$dataType'\n"; + } + + foreach ($this->getDatafieldsWithCategory() as $varname) { + echo "[-] Skipping migrating datafield '$varname' as it belongs to a category\n"; + } + + foreach ($this->getDatafieldsWithProtectedValues() as $varname) { + echo "[-] Skipping migrating datafield '$varname' as it is protected\n"; + } + + foreach ($this->getDatafieldsWithDuplicateNames() as $varname => $count) { + printf( + "[-] Skipping migrating datafield '%s' as there are '%d' datafields with same name\n", + $varname, + $count + ); + } + } + + if (! empty($customPropertiesToMigrate)) { + $this->migrateDatafields($customPropertiesToMigrate, $dryRun); + } + + echo "Migration completed\n"; + + $totalMigrated = count($customPropertiesToMigrate); + $totalSkipped = count(DirectorDatafield::loadAll($db)) - $totalMigrated; + + echo "Summary:\n"; + printf("Total datafields migrated: %d\n", $totalMigrated); + printf("Total datafields skipped: %d\n", $totalSkipped); + } + + /** + * Prepare custom properties to migrate + * + * @return array + */ + private function prepareCustomProperties(): array + { + $db = $this->db(); + $directorProperty = DirectorProperty::loadAll( + $db, + $db->getDbAdapter()->select()->from('director_property')->where('parent_uuid IS NULL'), + 'key_name' + ); + + $customProperties = []; + $migrationQuery = $this->getDataFieldsMigrationQuery(); + $typeOffset = strlen("Icinga\Module\Director\DataType\DataType"); + foreach ($migrationQuery as $row) { + if (isset($directorProperty[$row->varname])) { + $this->existingCustomProperties[] = $row->varname; + + continue; + } + + $customProperty = [ + 'datafield_id' => $row->id, + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $row->varname, + 'label' => $row->caption, + 'description' => $row->description, + 'category_id' => $row->category_id + ]; + $dataType = strtolower(substr($row->datatype, $typeOffset)); + + if ($dataType === 'array') { + $customProperty['value_type'] = 'dynamic-array'; + $customProperty['item_type'] = 'string'; + } elseif ($dataType === 'boolean' || $dataType === 'number' || $dataType === 'string') { + $customProperty['value_type'] = $dataType; + } elseif ($dataType === 'datalist') { + $datalist = DirectorDatafield::load($row->id, $db); + $settings = $datalist->getSettings(); + $behaviour = $settings['behavior']; + if ($behaviour === 'strict' || $behaviour === 'suggest_strict') { + $customProperty['value_type'] = 'datalist-strict'; + } else { + $customProperty['value_type'] = 'datalist-non-strict'; + } + + $customProperty['item_type'] = $settings['data_type'] === 'array' + ? 'dynamic-array' + : 'string'; + } else { + $customProperty['value_type'] = "unsupported-$dataType"; + } + + $customProperties[$row->varname] = $customProperty; + } + + return $customProperties; + } + + /** + * Migrate given prepared custom properties + * + * @param array $customProperties + * + * @return void + */ + private function migrateDatafields(array $customProperties, bool $dryRun): void + { + $db = $this->db(); + if (! $dryRun) { + $db->getDbAdapter()->beginTransaction(); + } + + foreach ($customProperties as $varName => $customProperty) { + if ($this->isVerbose && str_starts_with($customProperty['value_type'], 'unsupported-')) { + echo "[-] Skipping migration of datafield '{$varName}' as it has an unsupported datatype '" + . substr($customProperty['value_type'], strlen('unsupported-')) + . "'\n"; + + continue; + } + + $itemType = null; + if (isset($customProperty['item_type'])) { + $itemType = $customProperty['item_type']; + unset($customProperty['item_type']); + } + + if (! $dryRun) { + $datafieldId = $customProperty['datafield_id']; + unset($customProperty['datafield_id']); + $db->insert('director_property', $customProperty); + $propertyUuid = Uuid::fromBytes($customProperty['uuid']); + + if ($itemType !== null) { + $db->insert('director_property', [ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 0, + 'value_type' => $itemType, + 'parent_uuid' => $customProperty['uuid'] + ]); + } + + $this->migrateDatafieldObjectTemplateBinding($datafieldId, $propertyUuid); + } + + if ($this->isVerbose) { + echo "[+] Datafield '$varName' successfully migrated\n"; + } + } + + if (! $dryRun) { + $db->getDbAdapter()->commit(); + } + } + + /** + * Check what datafield types can be migrated + * + * @return void + */ + private function checkMigrateableDatafieldTypes(): void + { + $db = $this->db(); + printf("The following datafield types and the corresponding number of datafields can be migrated:\n"); + $total = 0; + $query = $this->getDataFieldsMigrationQuery(); + $typeOffset = strlen("Icinga\Module\Director\DataType\DataType"); + foreach ( + $db->select()->from( + ['q' => new DbSelectParenthesis($query->getSelectQuery())], + ['*', 'count_q' => 'COUNT(*)'] + )->group('datatype') as $row + ) { + printf( + "Data type: %s | count: %d\n", + substr($row->datatype, $typeOffset), + $row->count_q + ); + $total += $row->count_q; + } + + printf("Total datafields that can be migrated: %d\n\n", $total); + } + + /** + * Check what datafield types can not be migrated + * + * @return void + */ + private function checkUnmigrateableDatafieldTypes(): void + { + printf("The following datafield types and the corresponding number of datafields can not be migrated:\n"); + $total = 0; + $groupByDataType = []; + $typeOffset = strlen("Icinga\Module\Director\DataType\DataType"); + foreach ($this->getDatafieldsWithUnsupportedValuetype() as $varname => $datatype) { + $groupByDataType[$datatype][] = $varname; + $total++; + } + + foreach ($groupByDataType as $datatype => $datafields) { + printf("Data type: %s | count: %d\n", substr($datatype, $typeOffset), count($datafields)); + } + + if ($total > 0) { + printf( + "Total datafields that can not be migrated because of incompatible datatypes" + . " with new custom property support: %d\n\n", + $total + ); + } + } + + /** + * Get query for datafields that can be migrated + * + * @return DbQuery + */ + private function getDataFieldsMigrationQuery(): DbQuery + { + $query = $this->getDataFieldQuery(); + $skippedFields = array_merge( + array_keys($this->getDatafieldsWithDuplicateNames()), + array_keys($this->getDatafieldsWithUnsupportedValuetype()), + $this->getDatafieldsWithProtectedValues(), + $this->getDatafieldsWithCategory() + ); + + $query->addFilter(Filter::not(Filter::where('varname', $skippedFields))); + + return $query; + } + + /** + * Check what datafields can not be migrated because they belong to a category + * + * @return void + */ + private function checkDatafieldsWithCategory(): void + { + $count = count($this->getDatafieldsWithCategory()); + + if ($count > 0) { + printf("The following number of datafields belong to a category and can not be migrated: %d\n\n", $count); + } + } + + /** + * Check what datafields can not be migrated because they have duplicate names + * + * @return void + */ + private function checkDatafieldsWithDuplicateNames(): void + { + printf("The following datafields can not be migrated as there are duplicates:\n"); + $total = 0; + foreach ($this->getDatafieldsWithDuplicateNames() as $varname => $count) { + printf("Var name: %s | count: %d\n", $varname, $count); + $total += $count; + } + + printf("Total datafields that can not be migrated because of having duplicates: %d\n\n", $total); + } + + /** + * Check what datafields can not be migrated because they are protected + * + * @return void + */ + private function checkProtectedDatafields(): void + { + $count = count($this->getDatafieldsWithProtectedValues()); + + if ($count > 0) { + printf("The following number of datafields are protected and can not be migrated: %d\n\n", $count); + } + } + + /** + * Get query for datafields + * + * @return DbQuery + */ + private function getDataFieldQuery(): DbQuery + { + return $this->db()->select() + ->from( + ['dd' => 'director_datafield'], + [ + 'id' => 'dd.id', + 'varname' => 'dd.varname', + 'caption' => 'dd.caption', + 'description' => 'dd.description', + 'datatype' => 'dd.datatype', + 'category_id' => 'dd.category_id', + 'count' => 'COUNT(varname)' + ] + )->group('varname'); + } + + /** + * Get datafields with unsupported value type in new custom property support + * + * @return array + */ + private function getDatafieldsWithUnsupportedValuetype() + { + $query = $this->getDataFieldQuery(); + $query->addFilter(FilterAnd::matchAny( + FilterMatch::where('datatype', '*SqlQuery'), + FilterMatch::where('datatype', '*DirectorObject'), + FilterMatch::where('datatype', '*Dictionary') + )); + + $query->columns(['varname', 'datatype']); + + return $query->fetchPairs(); + } + + /** + * Get datafields with duplicate names + * + * @return array + */ + private function getDatafieldsWithDuplicateNames(): array + { + $query = $this->getDataFieldQuery(); + $query->columns(['varname', 'count' => 'COUNT(varname)']); + $query->select()->having('count > 1'); + + return $query->fetchPairs(); + } + + /** + * Get datafields with protected values + * + * @return array + */ + private function getDatafieldsWithProtectedValues(): array + { + $query = $this->getDataFieldQuery(); + $query->joinLeft( + ['dds' => 'director_datafield_setting'], + "dd.id = dds.datafield_id AND dds.setting_name = 'visibility'", + [] + ); + $query->addFilter(Filter::matchAll( + FilterMatch::where('dd.datatype', '*String'), + FilterMatch::where('dds.setting_value', 'hidden') + ))->addFilter(Filter::fromQueryString('category_id IS NULL')); + + $query->columns(['varname']); + + return $query->fetchColumn(); + } + + /** + * Get datafields with categories + * + * @return array + */ + private function getDatafieldsWithCategory(): array + { + $query = $this->getDataFieldQuery(); + $query->addFilter(Filter::fromQueryString('category_id IS NOT NULL')); + $query->columns(['varname']); + + return $query->fetchColumn(); + } + + private function migrateDatafieldObjectTemplateBinding(int $datafieldIdId, UuidInterface $propertyUuid): void + { + $db = $this->db(); + $objectTypes = ['host', 'service', 'notification', 'command', 'user']; + foreach ($objectTypes as $type) { + $query = $db->getDbAdapter()->select()->from(['io' => "icinga_{$type}"], ['*']) + ->join(['iof' => "icinga_{$type}_field"], "io.id = iof.{$type}_id", []) + ->where('iof.datafield_id = ?', $datafieldIdId); + + $objectInstance = DbObjectTypeRegistry::classByType($type); + $objects = $objectInstance::loadAll($db, $query); + foreach ($objects as $object) { + $db->insert( + "icinga_{$type}_property", + ['property_uuid' => $propertyUuid->getBytes(), "{$type}_uuid" => $object->get('uuid')] + ); + } + } + } +} 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/CustomvarController.php b/application/controllers/CustomvarController.php index f0d4574c0..037b5b26f 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -2,16 +2,444 @@ namespace Icinga\Module\Director\Controllers; -use Icinga\Module\Director\Web\Controller\ActionController; +use Icinga\Application\Config; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Forms\DeleteCustomVariableForm; +use Icinga\Module\Director\Forms\CustomVariableForm; use Icinga\Module\Director\Web\Table\CustomvarVariantsTable; +use Icinga\Module\Director\Web\Widget\CustomVarObjectList; +use Icinga\Module\Director\Web\Widget\CustomVarFieldsTable; +use Icinga\Web\Notification; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Web\Compat\CompatController; +use ipl\Web\Url; +use ipl\Web\Widget\ButtonLink; +use ipl\Web\Widget\EmptyStateBar; +use ipl\Web\Widget\ListItem; +use ipl\Web\Widget\Tabs; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use Zend_Db; +use Zend_Db_Expr; -class CustomvarController extends ActionController +class CustomvarController extends CompatController { + /** @var Db */ + protected $db; + + /** @var ?UuidInterface */ + private ?UuidInterface $uuid = null; + + /** @var ?UuidInterface */ + private ?UuidInterface $parentUuid = null; + + public function init() + { + parent::init(); + + $uuid = $this->params->shift('uuid'); + if ($uuid !== null) { + $this->uuid = Uuid::fromString($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 (empty($property)) { + $this->redirectNow(Url::fromPath('director/variables')); + } + + if ($parentUuid) { + $parentUuid = Uuid::fromString($parentUuid); + $parent = $this->fetchProperty($parentUuid); + + if ($parent['parent_uuid'] !== null) { + $usedCount = $this->fetchPropertyUsedCount(Uuid::fromBytes( + Db\DbUtil::binaryResult($parent['parent_uuid']) + )); + } else { + $usedCount = $this->fetchPropertyUsedCount($parentUuid); + } + } else { + $usedCount = $this->fetchPropertyUsedCount($uuid); + } + + $property['used_count'] = $usedCount; + + if ($property['value_type'] === 'dynamic-array' || str_starts_with($property['value_type'], 'datalist-')) { + $itemTypeQuery = $db + ->select()->from('director_property', 'value_type') + ->where( + 'parent_uuid = ? AND key_name = \'0\'', + Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db) + ); + + $property['item_type'] = $db->fetchOne($itemTypeQuery); + } + + if (str_starts_with($property['value_type'], 'datalist-')) { + $datalistId = $db + ->select()->from(['dl' => 'director_datalist'], 'id') + ->join(['dpl' => 'director_property_datalist'], 'dpl.list_uuid = dl.uuid', []) + ->where( + 'dpl.property_uuid = ?', + Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db) + ); + + $property['list'] = $db->fetchOne($datalistId); + } + + $showFields = $this->showFields($property['value_type']); + $propertyForm = (new CustomVariableForm($this->db, $uuid, $parentUuid !== null, $parentUuid)) + ->populate($property) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(CustomVariableForm::ON_SENT, function (CustomVariableForm $form) use ($property, &$showFields) { + $showFields = $showFields && $form->getValue('value_type') === $property['value_type']; + }) + ->on(CustomVariableForm::ON_SUBMIT, function (CustomVariableForm $form) { + Notification::success(sprintf( + $this->translate('Custom variable configuration "%s" has successfully been saved'), + $form->getValue('key_name') + )); + + $this->sendExtraUpdates(['#col1']); + $redirectUrl = Url::fromPath( + 'director/customvar', + ['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('Create Field')), + Url::fromPath('director/customvar/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', + 'category_id', + 'value_type', + 'label', + 'description', + 'used_count' => $property['used_count'] > 0 ? 'COUNT(1)' : '0', + ]) + ->where('parent_uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)) + ->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 CustomVarFieldsTable($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('Custom Variable') . ': ' . $property['key_name']; + } + + $this->setTitle($title); + $this->setTitleTab('customvar'); + $this->setAutorefreshInterval(10); + } + + public function usageAction(): void + { + $objectClass = null; + $usageList = (new CustomVarObjectList($this->fetchCustomVarUsage())) + ->setDetailActionsDisabled(false) + ->on( + CustomVarObjectList::BEFORE_ITEM_ADD, + function (ListItem $item, $data) use (&$objectClass, &$usageList) { + if ($objectClass !== $data->object_class) { + $usageList->addHtml( + HtmlElement::create( + 'li', + ['class' => 'list-item'], + HtmlElement::create('h2', content: ucfirst($data->object_class) . 's') + ) + ); + $objectClass = $data->object_class; + } + } + ); + + $this->addContent($usageList); + + $this->setTitle($this->translate('Custom Variable Usage')); + $this->setTitleTab('usage'); + $this->setAutorefreshInterval(10); + } + + /** + * Fetch the give custom variable usage in templates + * + * @return array + */ + private function fetchCustomVarUsage(): array + { + $uuid = $this->uuid; + $property = $this->fetchProperty($uuid); + $db = $this->db->getDbAdapter(); + if (isset($property['parent_uuid'])) { + $parentUuid = Uuid::fromBytes(Db\DbUtil::binaryResult($property['parent_uuid'])); + $this->parentUuid = $parentUuid; + $parentProperty = $this->fetchProperty($parentUuid); + if (isset($parentProperty['parent_uuid'])) { + $rootUuid = Uuid::fromBytes(Db\DbUtil::binaryResult($parentProperty['parent_uuid'])); + } else { + $rootUuid = $parentUuid; + } + + $uuid = $rootUuid; + } + + $objectClasses = ['host', 'service', 'notification', 'command', 'user']; + $usage = []; + + foreach ($objectClasses as $objectClass) { + $customPropertyQuery = $db + ->select() + ->from(['io' => "icinga_$objectClass"], []) + ->join(['iov' => "icinga_$objectClass" . '_var'], "io.id = iov.$objectClass" . '_id', []) + ->join(['dp' => 'director_property'], 'iov.property_uuid = dp.uuid', []); + + $unionQuery = $db + ->select() + ->from(['io' => "icinga_$objectClass"], []) + ->join(['iop' => "icinga_$objectClass" . '_property'], "iop.$objectClass" . '_uuid = io.uuid', []) + ->join(['dp' => 'director_property'], 'iop.property_uuid = dp.uuid', []); + + $columns = [ + 'name' => 'io.object_name', + 'type' => 'io.object_type', + 'object_class' => new Zend_Db_Expr("'$objectClass'") + ]; + + if ($objectClass === 'service') { + $customPropertyQuery = $customPropertyQuery->joinLeft( + ['ioh' => 'icinga_host'], + 'io.host_id = ioh.id', + [] + ); + $unionQuery = $unionQuery->joinLeft(['ioh' => 'icinga_host'], 'io.host_id = ioh.id', []); + $columns['host_name'] = 'ioh.object_name'; + } + + $customPropertyQuery = $customPropertyQuery + ->columns($columns) + ->where('dp.uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + $unionQuery = $unionQuery + ->columns($columns) + ->where('dp.uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + $usage[] = $db->fetchAll($db->select()->union([$customPropertyQuery, $unionQuery])); + } + + return array_merge(...$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('Create Field')); + $uuid = Uuid::fromString($uuid); + + $parent = $this->fetchProperty($uuid); + $propertyForm = (new CustomVariableForm($this->db, null, true, $uuid)) + ->setHideKeyNameElement($parent['value_type'] === 'fixed-array') + ->setIsNestedField($parent['parent_uuid'] !== null) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(CustomVariableForm::ON_SUBMIT, function (CustomVariableForm $form) { + Notification::success(sprintf( + $this->translate('Custom variable configuration "%s" has successfully been saved'), + $form->getValue('key_name') + )); + + $this->sendExtraUpdates(['#col1']); + $this->redirectNow( + Url::fromPath('director/customvar', ['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 DeleteCustomVariableForm($this->db, $property, $parent)) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(DeleteCustomVariableForm::ON_SUBMIT, function () { + Notification::success($this->translate('Custom variable configuration has been successfully deleted')); + $this->sendExtraUpdates(['#col1']); + $this->redirectNow('__CLOSE__'); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($form); + + $this->setTitle($this->translate('Delete Property') . ': ' . $property['key_name']); + } + public function variantsAction() { $varName = $this->params->getRequired('name'); - $this->addSingleTab($this->translate('Custom Variable')) - ->addTitle($this->translate('Custom Variable variants: %s'), $varName); - CustomvarVariantsTable::create($this->db(), $varName)->renderTo($this); + $title = sprintf($this->translate('Custom Variable variants: %s'), $varName); + $this->setTitle($title); + $this->addTitleTab($title); + $this->addContent(CustomvarVariantsTable::create($this->db, $varName)); + } + + /** + * 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', + 'category_id', + 'value_type', + 'label', + 'description' + ]) + ->where('uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + 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', []) + ->joinLeft(['isp' => 'icinga_service_property'], 'isp.property_uuid = dp.uuid', []) + ->joinLeft(['iup' => 'icinga_user_property'], 'isp.property_uuid = dp.uuid', []) + ->joinLeft(['icp' => 'icinga_command_property'], 'isp.property_uuid = dp.uuid', []) + ->joinLeft(['inp' => 'icinga_notification_property'], 'isp.property_uuid = dp.uuid', []) + ->columns([ + 'used_count' => 'COUNT(ihp.property_uuid) + COUNT(isp.property_uuid)' + . ' + COUNT(iup.property_uuid) + COUNT(icp.property_uuid)' + . ' + COUNT(inp.property_uuid)' + ]) + ->where('uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + 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/customvar', ['uuid' => $this->uuid->toString()]); + if ($this->parentUuid) { + $url->addParams(['parent_uuid' => $this->parentUuid->toString()]); + $label = $this->translate('Edit Field'); + } else { + $label = $this->translate('Custom Variable'); + } + + return $this->getTabs() + ->add('customvar', [ + 'label' => $label, + 'url' => $url + ]) + ->add('usage', [ + 'label' => $this->translate('Custom Variable Usage'), + 'url' => Url::fromPath( + 'director/customvar/usage', + ['uuid' => $this->uuid->toString()] + ) + ]); } } diff --git a/application/controllers/DataController.php b/application/controllers/DataController.php index 7480db244..035027f51 100644 --- a/application/controllers/DataController.php +++ b/application/controllers/DataController.php @@ -6,6 +6,7 @@ use Icinga\Exception\NotFoundError; use Icinga\Module\Director\Forms\DirectorDatalistEntryForm; use Icinga\Module\Director\Forms\DirectorDatalistForm; +use Icinga\Module\Director\Forms\IcingaHostDictionaryMemberForm; use Icinga\Module\Director\Forms\IcingaServiceDictionaryMemberForm; use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Objects\IcingaHost; @@ -193,7 +194,12 @@ public function dictionaryAction() 'object_name' => $field->getSetting('template_name') ], $connection); - $form = new IcingaServiceDictionaryMemberForm(); + if ($object instanceof IcingaHost) { + $form = new IcingaHostDictionaryMemberForm(); + } else { + $form = new IcingaServiceDictionaryMemberForm(); + } + $form->setDb($connection); if ($instance) { $instanceObject = $object::create([ diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php index 66588b840..a4ea8f45e 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\HostServiceBlacklistForm; +use Icinga\Module\Director\Forms\IcingaServiceForm; use Icinga\Module\Director\Integration\Icingadb\IcingadbBackend; use Icinga\Module\Director\Integration\MonitoringModule\Monitoring; use Icinga\Module\Director\Web\Table\ObjectsTableService; @@ -12,11 +14,9 @@ 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; -use Icinga\Module\Director\Forms\IcingaServiceForm; use Icinga\Module\Director\Forms\IcingaServiceSetForm; use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaService; @@ -25,7 +25,6 @@ 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; @@ -99,7 +98,7 @@ public function serviceAction() ->setBranch($this->getBranch()) ->setHost($host) ->setDb($this->db()) - ->handleRequest() + ->handleRequest(), ); } @@ -114,7 +113,7 @@ public function servicesetAction() ->setBranch($this->getBranch()) ->setHost($host) ->setDb($this->db()) - ->handleRequest() + ->handleRequest(), ); } @@ -128,12 +127,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 +158,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 +178,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 +198,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), ))); } } @@ -250,11 +249,19 @@ public function servicesAction() ->removeQueryLimit(); if (count($table)) { + $deprecatedTable = clone $table; $content->add( $table->setTitle(sprintf( $this->translate('Inherited from %s'), $parent->getObjectName() - )) + )), + ); + + $content->add( + $deprecatedTable->setTitle(sprintf( + $this->translate('Inherited from %s (referencing deprecated Datafields)'), + $parent->getObjectName(), + ))->useDeprecatedLink() ); } } @@ -268,23 +275,36 @@ public function servicesAction() $appliedSets = AppliedServiceSetLoader::fetchForHost($host); foreach ($appliedSets as $set) { - $title = sprintf($this->translate('%s (Applied Service set)'), $set->getObjectName()); + $servicesetserviceTable = IcingaServiceSetServiceTable::load($set) + ->setBranch($branch) + ->setAffectedHost($host) + ->removeQueryLimit(); + + $deprecatedservicesetserviceTable = clone $servicesetserviceTable; + $content->add($servicesetserviceTable->setTitle(sprintf( + $this->translate('%s (Applied Service set)'), + $set->getObjectName() + ))); $content->add( - IcingaServiceSetServiceTable::load($set) - // ->setHost($host) - ->setBranch($branch) - ->setAffectedHost($host) - ->setTitle($title) - ->removeQueryLimit() + $deprecatedservicesetserviceTable + ->setTitle(sprintf($this->translate( + '%s (Applied Service set [referencing deprecated Datafields])' + ), $set->getObjectName())) + ->useDeprecatedLink() ); } - $table = IcingaHostAppliedServicesTable::load($host) - ->setTitle($this->translate('Applied services')); + $table = IcingaHostAppliedServicesTable::load($host); if (count($table)) { - $content->add($table); + $deprecatedTable = clone $table; + $content->add($table->setTitle($this->translate('Applied services'))); + $content->add( + $deprecatedTable + ->setTitle($this->translate('Applied services (referencing deprecated Datafields)')) + ->useDeprecatedLink() + ); } } @@ -334,8 +354,8 @@ public function servicesroAction() $content->add( $table->setTitle(sprintf( 'Inherited from %s', - $parent->getObjectName() - )) + $parent->getObjectName(), + )), ); } } @@ -356,7 +376,7 @@ public function servicesroAction() ->setAffectedHost($host) ->setReadonly() ->highlightService($service) - ->setTitle($title) + ->setTitle($title), ); } @@ -387,15 +407,15 @@ protected function addHostServiceSetTables(IcingaHost $host, ?IcingaHost $affect $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'); @@ -406,12 +426,23 @@ protected function addHostServiceSetTables(IcingaHost $host, ?IcingaHost $affect ->setHost($host) ->setBranch($this->getBranch()) ->setAffectedHost($affectedHost) - ->removeQueryLimit() - ->setTitle($title); + ->removeQueryLimit(); + + $deprecatedTable = clone $table; + + $table->setTitle($title); + $deprecatedTable + ->useDeprecatedLink() + ->setTitle(sprintf( + $this->translate('%s (Service set [referencing deprecated Datafields])'), + $name + )); if ($roService) { $table->setReadonly()->highlightService($roService); + $deprecatedTable->setReadonly()->highlightService($roService); } $this->content()->add($table); + $this->content()->add($deprecatedTable); } } @@ -426,17 +457,55 @@ public function appliedserviceAction() $parent = IcingaService::loadWithAutoIncId($serviceId, $db); $serviceName = $parent->getObjectName(); + $this->addTitle( + $this->translate('Applied service: %s'), + $serviceName, + ); + + $deactivateForm = (new HostServiceBlacklistForm($db, $host, $parent)) + ->on(HostServiceBlacklistForm::ON_SUCCESS, function () { + $this->redirectNow(Url::fromRequest()); + }) + ->handleRequest($this->getServerRequest()); + + if ($deactivateForm->hasBeenBlacklisted()) { + $this->content()->add( + Hint::warning($this->translate('This Service has been deactivated on this host')) + ); + $this->content()->add($deactivateForm); + } else { + $this->controls()->prepend($deactivateForm); + $form = $this->prepareCustomPropertiesForm($parent, $host); + $form->setApplyGenerated($parent); + $form->setHostForService($host); + $this->content()->add($form->handleRequest($this->getServerRequest())); + } + + $this->commonForServices(); + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + public function appliedservicedeprecatedAction() + { + $db = $this->db(); + $host = $this->getHostObject(); + $serviceId = $this->params->get('service_id'); + $parent = IcingaService::loadWithAutoIncId($serviceId, $db); + $serviceName = $parent->getObjectName(); + $service = IcingaService::create([ - 'imports' => $parent, + 'imports' => $parent, 'object_type' => 'apply', 'object_name' => $serviceName, - 'host_id' => $host->get('id'), - 'vars' => $host->getOverriddenServiceVars($serviceName), + 'host_id' => $host->get('id'), + 'vars' => $host->getOverriddenServiceVars($serviceName), ], $db); $this->addTitle( $this->translate('Applied service: %s'), - $serviceName + $serviceName, ); $this->content()->add( @@ -446,7 +515,7 @@ public function appliedserviceAction() ->setHost($host) ->setApplyGenerated($parent) ->setObject($service) - ->handleRequest() + ->handleRequest(), ); $this->commonForServices(); @@ -455,16 +524,19 @@ public function appliedserviceAction() /** * @throws \Icinga\Exception\NotFoundError */ - public function inheritedserviceAction() + public function inheritedservicedeprecatedAction() { $db = $this->db(); $host = $this->getHostObject(); $serviceName = $this->params->get('service'); - $from = IcingaHost::load($this->params->get('inheritedFrom'), $this->db()); + $from = IcingaHost::load( + $this->params->get('inheritedFrom'), + $this->db() + ); $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 @@ -474,12 +546,15 @@ public function inheritedserviceAction() $service = IcingaService::create([ 'object_type' => 'apply', 'object_name' => $serviceName, - 'host_id' => $host->get('id'), - 'imports' => [$parent], - 'vars' => $host->getOverriddenServiceVars($serviceName), + 'host_id' => $host->get('id'), + 'imports' => [$parent], + 'vars' => $host->getOverriddenServiceVars($serviceName), ], $db); - $this->addTitle($this->translate('Inherited service: %s'), $serviceName); + $this->addTitle( + $this->translate('Inherited service: %s'), + $serviceName + ); $form = IcingaServiceForm::load() ->setDb($db) @@ -492,6 +567,53 @@ public function inheritedserviceAction() $this->commonForServices(); } + /** + * @throws \Icinga\Exception\NotFoundError + */ + public function inheritedserviceAction() + { + $host = $this->getHostObject(); + $serviceName = $this->params->get('service'); + $db = $this->db(); + $from = IcingaHost::load($this->params->get('inheritedFrom'), $db); + $parent = IcingaService::load([ + 'object_name' => $serviceName, + 'host_id' => $from->get('id'), + ], $db); + $deactivateForm = (new HostServiceBlacklistForm($db, $host, $parent)) + ->on(HostServiceBlacklistForm::ON_SUCCESS, function () { + $this->redirectNow(Url::fromRequest()); + }) + ->handleRequest($this->getServerRequest()); + + $this->addTitle( + $this->translate('Inherited service: %s'), + $serviceName + ); + if ($deactivateForm->hasBeenBlacklisted()) { + $this->content()->add( + Hint::warning( + $this->translate( + 'This Service has been deactivated on this host' + ) + ) + ); + + $this->content()->add($deactivateForm); + } else { + $this->controls()->prepend($deactivateForm); + $form = $this->prepareCustomPropertiesForm($parent, $host); + $form->setInheritedServiceFrom($from); + $form->setHostForService($host); + + $this->content()->add( + $form->handleRequest($this->getServerRequest()) + ); + } + + $this->commonForServices(); + } + /** * @throws \Icinga\Exception\NotFoundError */ @@ -501,21 +623,22 @@ 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(); + IcingaServiceSet::loadWithAutoIncId($db->fetchOne($query), $this->db()) + ->delete(); $this->redirectNow( Url::fromPath('director/host/services', array( - 'name' => $this->object->getObjectName() - )) + 'name' => $this->object->getObjectName(), + )), ); } @@ -529,7 +652,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)) { @@ -538,17 +661,73 @@ public function servicesetserviceAction() $set = $setTemplate; } - $service = IcingaService::load([ + $originalService = IcingaService::load([ 'object_name' => $serviceName, - 'service_set_id' => $setTemplate->get('id') + 'service_set_id' => $setTemplate->get('id'), + ], $this->db()); + + // $set->copyVarsToService($service); + $this->addTitle( + $this->translate('%s on %s (from set: %s)'), + $serviceName, + $host->getObjectName(), + $set->getObjectName(), + ); + + $deactivateForm = (new HostServiceBlacklistForm($db, $host, $originalService)) + ->on(HostServiceBlacklistForm::ON_SUCCESS, function () { + $this->redirectNow(Url::fromRequest()); + }) + ->handleRequest($this->getServerRequest()); + + if ($deactivateForm->hasBeenBlacklisted()) { + $this->content()->add(Hint::warning($this->translate('This Service has been deactivated on this host'))); + $this->content()->add($deactivateForm); + } else { + $this->controls()->prepend($deactivateForm); + $form = $this->prepareCustomPropertiesForm($originalService, $host); + $form->setServiceSet($setTemplate); + $form->setHostForService($host); + + $this->tabs()->activate('services'); + $this->content()->add( + $form->handleRequest($this->getServerRequest()) + ); + } + + $this->commonForServices(); + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + public function servicesetservicedeprecatedAction() + { + $db = $this->db(); + $host = $this->getHostObject(); + $serviceName = $this->params->get('service'); + $setParams = [ + 'object_name' => $this->params->get('set'), + 'host_id' => $host->get('id'), + ]; + $setTemplate = IcingaServiceSet::load($this->params->get('set'), $db); + if (IcingaServiceSet::exists($setParams, $db)) { + $set = IcingaServiceSet::load($setParams, $db); + } else { + $set = $setTemplate; + } + + $service = IcingaService::load([ + 'object_name' => $serviceName, + 'service_set_id' => $setTemplate->get('id'), ], $this->db()); $service = IcingaService::create([ - 'id' => $service->get('id'), + 'id' => $service->get('id'), 'object_type' => 'apply', 'object_name' => $serviceName, - 'host_id' => $host->get('id'), - 'imports' => $service->listImportNames(), - 'vars' => $host->getOverriddenServiceVars($serviceName), + 'host_id' => $host->get('id'), + 'imports' => $service->listImportNames(), + 'vars' => $host->getOverriddenServiceVars($serviceName), ], $db); // $set->copyVarsToService($service); @@ -578,7 +757,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'); } @@ -613,8 +792,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 @@ -637,12 +816,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/SuggestionsController.php b/application/controllers/SuggestionsController.php new file mode 100644 index 000000000..5acf15cdd --- /dev/null +++ b/application/controllers/SuggestionsController.php @@ -0,0 +1,69 @@ +params->shiftRequired('uuid')); + $this->db = Db::fromResourceName( + Config::module('director')->get('db', 'resource') + ); + + $excludeTerms = []; + + if ($this->params->has('exclude')) { + $excludeTerms = explode(',', $this->params->get('exclude')); + } + + if (! empty($excludeTerms)) { + foreach ($excludeTerms as $excludeTerm) { + $excludes->add(iplFilter::equal('entry_name', $excludeTerm)); + } + } + + $suggestions = new SearchSuggestions((function () use ($uuid, $excludes, &$suggestions) { + foreach ($suggestions->getExcludeTerms() as $excludeTerm) { + $excludes->add(iplFilter::equal('entry_name', $excludeTerm)); + } + + $query = $this->db->select()->from(['dle' => 'director_datalist_entry'], ['entry_name', 'entry_value']) + ->join(['dl' => 'director_datalist'], 'dl.id = dle.list_id', []) + ->join(['dpl' => 'director_property_datalist'], 'dl.uuid = dpl.list_uuid', []) + ->where('dpl.property_uuid', $uuid->getBytes()); + + $filterString = QueryString::render(iplFilter::all($excludes)); + if ($filterString !== '') { + $query->addFilter(Filter::fromQueryString($filterString)); + } + + foreach ($this->db->fetchPairs($query) as $name => $value) { + yield [ + 'search' => $name, + 'label' => $value, + 'class' => 'list-entry' + ]; + } + })()); + + $this->getDocument()->addHtml($suggestions->forRequest($this->getServerRequest())); + } +} diff --git a/application/controllers/VariablesController.php b/application/controllers/VariablesController.php new file mode 100644 index 000000000..ba6a53656 --- /dev/null +++ b/application/controllers/VariablesController.php @@ -0,0 +1,83 @@ +addTitleTab($this->translate('Custom Variables')); + + $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', []) + ->joinLeft(['isp' => 'icinga_service_property'], 'isp.property_uuid = dp.uuid', []) + ->joinLeft(['iup' => 'icinga_user_property'], 'isp.property_uuid = dp.uuid', []) + ->joinLeft(['icp' => 'icinga_command_property'], 'isp.property_uuid = dp.uuid', []) + ->joinLeft(['inp' => 'icinga_notification_property'], 'isp.property_uuid = dp.uuid', []) + ->columns([ + 'key_name', + 'uuid', + 'parent_uuid', + 'value_type', + 'label', + 'description', + 'used_count' => 'COUNT(ihp.property_uuid) + COUNT(isp.property_uuid)' + . ' + COUNT(iup.property_uuid) + COUNT(icp.property_uuid) + COUNT(inp.property_uuid)' + ]) + ->where('parent_uuid IS NULL') + ->group('dp.uuid') + ->order('key_name'); + + $properties = new CustomVarFieldsTable($db->fetchAll($query)); + + $this->addControl(Html::tag('div', ['class' => 'custom-variable-form'], [ + (new ButtonLink( + [Text::create($this->translate('Create Custom Variable'))], + Url::fromPath('director/variables/add'), + null, + [ + 'class' => 'control-button' + ] + ))->setBaseTarget('_next') + ])); + + $this->addContent($properties); + } + + public function addAction() + { + $this->addTitleTab($this->translate('Create Custom Variable')); + $db = Db::fromResourceName( + Config::module('director')->get('db', 'resource') + ); + + $propertyForm = (new CustomVariableForm($db)) + ->on(CustomVariableForm::ON_SUBMIT, function (CustomVariableForm $form) { + Notification::success(sprintf( + $this->translate('Property "%s" has successfully been added'), + $form->getValue('key_name') + )); + + $this->redirectNow(Url::fromPath('director/customvar', ['uuid' => $form->getUUid()->toString()])); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($propertyForm); + } +} diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php new file mode 100644 index 000000000..08dd4a02c --- /dev/null +++ b/application/forms/CustomVariableForm.php @@ -0,0 +1,607 @@ +getAttributes()->add(['class' => ['custom-variable-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 = ?', Db\DbUtil::quoteBinaryCompat($this->parentUuid->getBytes(), $db)); + + $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')] + ); + + if ($this->parentUuid === null) { + $this->addElement( + 'select', + 'category_id', + [ + 'label' => $this->translate('Category'), + 'value' => '', + 'options' => ['' => $this->translate('- please choose -')] + $this->fetchCategories() + ] + ); + } + + $types = [ + 'string' => 'String', + 'number' => 'Number', + 'bool' => 'Boolean', + 'datalist-strict' => 'Data List Strict', + 'datalist-non-strict' => 'Data List Non Strict', + ]; + + 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, + 'disabled' => $used, + 'title' => $used ? $this->translate( + 'This property is used in one or more templates and hence the value type' + . ' cannot be changed.' + ) : '', + ] + ); + + $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), + 'disabled' => $used, + 'title' => $used ? $this->translate( + 'This property is used in one or more templates and hence the item type' + . ' cannot be changed.' + ) : '' + ] + ); + } elseif (str_starts_with($type, 'datalist')) { + $isStrict = substr_compare($type, 'strict', strlen('datalist-')) === 0; + $this->getElement('value_type')->setAttribute('strict', $isStrict); + $this->addElement( + 'select', + 'list', + [ + 'label' => $this->translate('List name'), + 'class' => 'autosubmit', + 'disabledOptions' => [''], + 'value' => '', + 'required' => true, + 'options' => ['' => $this->translate('- please choose -')] + $this->enumDatalist(), + 'disabled' => $used, + 'title' => $used ? $this->translate( + 'This property is used in one or more templates and hence the datalist' + . ' cannot be changed.' + ) : '' + ] + ); + + $this->addElement( + 'select', + 'item_type', + [ + 'label' => $this->translate('Item Type'), + 'class' => 'autosubmit', + 'disabledOptions' => [''], + 'value' => 'string', + 'options' => ['string' => 'String', 'dynamic-array' => 'Array'] + ] + ); + + 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/customvar/delete', + ['uuid' => $this->uuid->toString()] + ), + null, + ['class' => ['btn-remove']] + ))->openInModal() + ); + } + } + + private function enumDatalist(): array + { + return $this->db->fetchPairs( + $this->db->select()->from('director_datalist', ['id', 'list_name'])->order('list_name') + ); + } + + private function fetchDatalist(int $id): array + { + return (array) $this->db->fetchRow( + $this->db->select()->from('director_datalist', ['*']) + ->where('id', $id) + ); + } + + private function fetchCategories(): array + { + return $this->db->fetchPairs( + $this->db->select()->from('director_datafield_category', ['id', 'category_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 = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + 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(); + $datalist = []; + $itemType = ''; + $valueType = $values['value_type']; + if (str_starts_with($valueType, 'datalist-')) { + $datalist = $this->fetchDatalist($values['list']); + $itemType = $values['item_type']; + unset($values['list']); + } elseif ($valueType == 'dynamic-array') { + $itemType = $values['item_type']; + } + + if (isset($values['list'])) { + unset($values['list']); + } + + if (isset($values['item_type'])) { + unset($values['item_type']); + } + + $this->db->getDbAdapter()->beginTransaction(); + if ($this->uuid === null) { + $this->addNewProperty($values, $datalist, $itemType); + } else { + $this->updateExistingProperty($values, $datalist, $itemType); + } + + $this->db->getDbAdapter()->commit(); + } + + /** + * Add a new custom property + * + * @param array $values Form values + * @param array $datalist Datalist values if any + * @param string $itemType Item type if any + * + * @return void + */ + private function addNewProperty( + array $values, + array $datalist = [], + string $itemType = '' + ): void { + $this->uuid = Uuid::uuid4(); + $quotedUuid = Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()); + $dynamicArrayItemType = []; + if ($itemType !== '') { + $dynamicArrayItemType = [ + 'uuid' => Db\DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $this->db->getDbAdapter()), + 'key_name' => '0', + 'value_type' => $itemType, + 'parent_uuid' => $quotedUuid + ]; + } + + if ($this->field) { + $quotedParentUuid = Db\DbUtil::quoteBinaryCompat($this->parentUuid->getBytes(), $this->db->getDbAdapter()); + $values = array_merge( + [ + 'uuid' => $quotedUuid, + 'parent_uuid' => $quotedParentUuid + ], + $values + ); + } else { + $values = array_merge( + ['uuid' => $quotedUuid], + $values + ); + } + + $this->db->insert('director_property', $values); + + if (! empty($dynamicArrayItemType)) { + $this->db->insert('director_property', $dynamicArrayItemType); + } + + if (! empty($datalist)) { + $this->db->insert('director_property_datalist', [ + 'property_uuid' => $quotedUuid, + 'list_uuid' => Db\DbUtil::quoteBinaryCompat($datalist['uuid'], $this->db->getDbAdapter()), + ]); + } + } + + /** + * Update an existing property + * + * @param array $values Form values + * @param array $datalist Datalist values if any + * @param string $itemType Item type if any + * + * @return void + * + * @throws FilterException + */ + private function updateExistingProperty( + array $values, + array $datalist = [], + string $itemType = '' + ): void { + $used = (int) $this->getValue('used_count') > 0; + $valueType = $values['value_type']; + if (isset($values['used_count'])) { + unset($values['used_count']); + } + + if (! $used) { + $dbProperty = $this->fetchProperty($this->uuid); + if ( + $dbProperty['value_type'] !== $valueType + || ( + $dbProperty['value_type'] === 'dynamic-array' + || str_starts_with($dbProperty['value_type'], 'datalist-') + ) + ) { + $this->db->delete( + 'director_property', + Filter::matchAll(Filter::where( + 'parent_uuid', + Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()) + )) + ); + + $this->db->delete( + 'director_property_datalist', + Filter::matchAll(Filter::where( + 'property_uuid', + Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()) + )) + ); + } + + if ($itemType && ($valueType === 'dynamic-array' || str_starts_with($valueType, 'datalist-'))) { + $this->db->insert('director_property', [ + 'uuid' => Db\DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $this->db->getDbAdapter()), + 'key_name' => '0', + 'value_type' => $itemType, + 'parent_uuid' => Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()), + ]); + + if (str_starts_with($valueType, 'datalist-')) { + $this->db->insert('director_property_datalist', [ + 'property_uuid' => Db\DbUtil::quoteBinaryCompat( + $this->uuid->getBytes(), + $this->db->getDbAdapter() + ), + 'list_uuid' => Db\DbUtil::quoteBinaryCompat($datalist['uuid'], $this->db->getDbAdapter()), + ]); + } + } + } else { + $storedKeyName = $this->db->fetchOne( + $this->db->select() + ->from('director_property', ['key_name']) + ->where('uuid', Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter())) + ); + + if ($storedKeyName !== $values['key_name']) { + $this->updateUsedCustomVarNames($storedKeyName, $values['key_name']); + } + } + + $this->db->update( + 'director_property', + $values, + Filter::where('uuid', Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter())) + ); + } + + /** + * Update the used custom variable names in the database + * + * @param string $storedKeyName + * @param mixed $keyName + * + * @return void + * + * @throws FilterException + * @throws Zend_Db_Select_Exception + */ + private function updateUsedCustomVarNames(string $storedKeyName, mixed $keyName): void + { + $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); + $objectTypes = ['host', 'service', 'notification', 'command', 'user']; + + foreach ($objectTypes as $objectType) { + $objectCustomVars = $db->fetchAll( + $db->select() + ->from(['ihv' => "icinga_{$objectType}_var"], []) + ->columns([ + "{$objectType}_id", + 'varname', + 'varvalue', + 'property_uuid' + ]) + ->where('property_uuid = ?', Db\DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), + [], + PDO::FETCH_ASSOC + ); + + if (! $this->parentUuid) { + foreach ($objectCustomVars as $objectCustomVar) { + $this->db->update( + "icinga_{$objectType}_var", + ['varname' => $keyName], + Filter::matchAll( + Filter::where('property_uuid', Db\DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), + Filter::where("{$objectType}_id", $objectCustomVar["{$objectType}_id"]) + ) + ); + } + + return; + } + + foreach ($objectCustomVars as $objectCustomVar) { + $varValue = json_decode($objectCustomVar['varvalue'], true); + if ($root['value_type'] !== 'dynamic-dictionary') { + $this->updateObjectCustomVars([$storedKeyName], [$keyName], $varValue); + } else { + foreach ($varValue as $key => $value) { + if (! $this->isNestedField) { + $this->updateObjectCustomVars([$storedKeyName], [$keyName], $value); + } else { + $parenKey = $parent['key_name']; + $this->updateObjectCustomVars( + [$parenKey, $storedKeyName], + [$parenKey, $keyName], + $value + ); + } + + $varValue[$key] = $value; + } + } + + $this->db->update( + "icinga_{$objectType}_var", + ['varvalue' => json_encode($varValue)], + Filter::matchAll( + Filter::where('property_uuid', Db\DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), + Filter::where("{$objectType}_id", $objectCustomVar["{$objectType}_id"]) + ) + ); + } + } + } +} diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php new file mode 100644 index 000000000..82c297720 --- /dev/null +++ b/application/forms/CustomVariablesForm.php @@ -0,0 +1,323 @@ +addAttributes(Attributes::create(['class' => 'custom-variables-form'])); + } + + /** + * Check if the custom properties have been modified + * + * @return bool + */ + public function varsHasBeenModified(): bool + { + return $this->varsHasBeenModified; + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $dictionary = (new Dictionary( + 'properties', + $this->objectProperties, + ['class' => 'no-border'] + ))->setAllowItemRemoval($this->object->isTemplate()); + + $saveButton = $this->createElement('submit', 'save', [ + 'label' => $this->isOverrideServiceVars() + ? $this->translate('Override Custom Variables') + : $this->translate('Save Custom Variables') + ]); + + $this->addElement($this->duplicateSubmitButton($saveButton)); + $this->addElement($dictionary); + if ($this->hasBeenSent()) { + $dictionary->ensureAssembled(); + } + + $this->registerElement($saveButton); + + $removedItems = Session::getSession() + ->getNamespace('director.variables')->get('removed-properties', []); + if (! empty($removedItems)) { + $this->addHtml( + new HtmlElement('div', Attributes::create(['class' => 'message']), Text::create( + sprintf( + $this->translatePlural( + '(%d) property has been removed', + '(%d) properties have been removed', + count($removedItems) + ), + count($removedItems) + ) + )) + ); + } + + $this->addElement($saveButton); + } + + /** + * Set the applied rule from where the custom variables are inherited from + * + * @param IcingaService $applyGenerated + * + * @return $this + */ + public function setApplyGenerated(IcingaService $applyGenerated): static + { + $this->applyGenerated = $applyGenerated; + + return $this; + } + + public function setInheritedServiceFrom(string $hostname): static + { + $this->inheritedServiceFrom = $hostname; + + return $this; + } + + /** + * Set the service set from where the custom variables are inherited from + * + * @param IcingaServiceSet $set + * + * @return $this + */ + public function setServiceSet(IcingaServiceSet $set): static + { + $this->set = $set; + + return $this; + } + + /** + * Set host if the object is a service + * + * @param IcingaHost $host + * + * @return $this + */ + public function setHostForService(IcingaHost $host): static + { + $this->host = $host; + + return $this; + } + + /** + * Are the populated values for custom properties a part of _override_servicevars + * + * @return bool + */ + public function isOverrideServiceVars(): bool + { + return $this->applyGenerated + || $this->inheritedServiceFrom + || ($this->host && $this->set); + } + + public function hasBeenSubmitted(): bool + { + $pressedButton = $this->getPressedSubmitElement(); + + if ($pressedButton && $pressedButton->getName() === 'save') { + return true; + } + + return false; + } + + /** + * 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 + */ + public static function filterEmpty(array $array): array + { + return array_filter( + array_map(function ($item) { + if (! is_array($item)) { + // Recursively clean nested arrays + return $item; + } + + return self::filterEmpty($item); + }, $array), + function ($item) { + return is_bool($item) || ! empty($item); + } + ); + } + + protected function onSuccess(): void + { + $session = Session::getSession(); + $session->delete('properties'); + $session->delete('vars'); + $vars = $this->object->vars(); + + /** @var Dictionary $propertiesElement */ + $propertiesElement = $this->getElement('properties'); + $values = $propertiesElement->getDictionary(); + $itemsToRemove = $propertiesElement->getItemsToRemove(); + $type = $this->object->getShortTableName(); + $db = $this->object->getDb(); + foreach ($this->objectProperties as $key => $property) { + $propertyUuid = Uuid::fromBytes($property['uuid']); + if (isset($property['removed'])) { + $itemsToRemoveUuids[] = $property['uuid']; + continue; + } + + if (in_array($key, $itemsToRemove)) { + $itemsToRemoveUuids[] = $property['uuid']; + $this->varsHasBeenModified = true; + + continue; + } + + $value = $values[$key] ?? null; + + if (is_array($value)) { + $filteredValue = self::filterEmpty($value); + // Store the fixed array as empty only if the filtered array is empty + if ($property['value_type'] !== 'fixed-array' || empty($filteredValue)) { + $value = $filteredValue; + } + } + + if (isset($property['new'])) { + $this->object->getConnection()->insert( + "icinga_$type" . '_property', + [ + $type . '_uuid' => DbUtil::quoteBinaryCompat($this->object->uuid, $db), + 'property_uuid' => DbUtil::quoteBinaryCompat($propertyUuid->getBytes(), $db) + ] + ); + } + + 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, $propertyUuid); + } + + if ($this->varsHasBeenModified === false && $vars->hasBeenModified()) { + $this->varsHasBeenModified = true; + } + } + + if (! empty($itemsToRemove)) { + $objectId = (int) $this->object->get('id'); + $db = $this->object->getDb(); + + $objectsToCleanUp = [$objectId]; + $propertyAsObjectVar = $db->fetchAll( + $db + ->select() + ->from('icinga_' . $type . '_var') + ->where('property_uuid IN (?)', DbUtil::quoteBinaryCompat($itemsToRemoveUuids, $db)) + ); + + foreach ($propertyAsObjectVar as $propertyAsObjectVarRow) { + $class = DbObjectTypeRegistry::classByType($type); + $object = $class::loadWithAutoIncId( + $propertyAsObjectVarRow->{$type . '_id'}, + $this->object->getConnection() + ); + + if (in_array($objectId, $object->listAncestorIds(), true)) { + $objectsToCleanUp[] = (int) $object->get('id'); + } + } + + $propertyWhere = $this->object->getDb()->quoteInto('property_uuid IN (?)', $itemsToRemoveUuids); + $objectsWhere = $this->object->getDb()->quoteInto($type . '_id IN (?)', $objectsToCleanUp); + $db->delete('icinga_' . $type . '_var', $propertyWhere . ' AND ' . $objectsWhere); + + $objectWhere = $this->object->getDb()->quoteInto($type . '_uuid = ?', $this->object->get('uuid')); + $db->delete( + 'icinga_' . $type . '_property', + $propertyWhere . ' AND ' . $objectWhere + ); + } + + if ($this->isOverrideServiceVars()) { + $object = $this->host; + $overrideVars = (array) $this->host->getOverriddenServiceVars($this->object->getObjectName()); + foreach ($vars as $varName => $var) { + if ($var->hasBeenModified()) { + $overrideVars[$varName] = $var->getValue(); + } + } + + $object->overrideServiceVars($this->object->getObjectName(), (object) $overrideVars); + DirectorActivityLog::logModification($object, $this->object->getConnection()); + + $object->store($this->object->getConnection()); + } else { + $object = $this->object; + DirectorActivityLog::logModification($object, $this->object->getConnection()); + $vars->storeToDb($object); + } + } +} diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php new file mode 100644 index 000000000..42e8b935e --- /dev/null +++ b/application/forms/DeleteCustomVariableForm.php @@ -0,0 +1,492 @@ +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']; + } + + $objectClasses = ['host', 'service', 'notification', 'command', 'user']; + $usage = []; + + foreach ($objectClasses as $objectClass) { + $customPropertyQuery = $db + ->select() + ->from(['io' => "icinga_$objectClass"], []) + ->join(['iov' => "icinga_$objectClass" . '_var'], "io.id = iov.$objectClass" . '_id', []) + ->join(['dp' => 'director_property'], 'iov.property_uuid = dp.uuid', []); + + $unionQuery = $db + ->select() + ->from(['io' => "icinga_$objectClass"], []) + ->join(['iop' => "icinga_$objectClass" . '_property'], "iop.$objectClass" . '_uuid = io.uuid', []) + ->join(['dp' => 'director_property'], 'iop.property_uuid = dp.uuid', []); + + $columns = [ + 'name' => 'io.object_name', + 'object_class' => new Zend_Db_Expr("'$objectClass'"), + 'type' => 'io.object_type' + ]; + + if ($objectClass === 'service') { + $customPropertyQuery = $customPropertyQuery + ->joinLeft(['ioh' => 'icinga_host'], 'io.host_id = ioh.id', []); + $unionQuery = $unionQuery->joinLeft(['ioh' => 'icinga_host'], 'io.host_id = ioh.id', []); + $columns['host_name'] = 'ioh.object_name'; + } + + $customPropertyQuery = $customPropertyQuery->columns($columns) + ->where('dp.uuid = ?', Uuid::fromBytes($uuid)->getBytes()); + + + $unionQuery = $unionQuery->columns($columns) + ->where('dp.uuid = ?', $uuid); + + $usage[] = $db->fetchAll($db->select()->union([$customPropertyQuery, $unionQuery])); + } + + return array_merge(...$usage); + } + + 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)) + ) + )); + + $objectClass = null; + $usageList = (new CustomVarObjectList($customVarUsage)); + $usageList->on( + CustomVarObjectList::BEFORE_ITEM_ADD, + function (ListItem $item, $data) use (&$objectClass, $usageList) { + if ($objectClass !== $data->object_class) { + $usageList->addHtml( + HtmlElement::create( + 'li', + ['class' => 'list-item'], + HtmlElement::create('h2', content: ucfirst($data->object_class) . 's') + ) + ); + $objectClass = $data->object_class; + } + } + ); + + $this->addHtml($usageList); + + $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'], []) + ->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']); + $db = $this->db; + + $db->getDbAdapter()->beginTransaction(); + $prop = $this->property; + + if (str_starts_with($prop['value_type'], 'datalist-')) { + $db->delete('director_property_datalist', Filter::where('property_uuid', $uuid->getBytes())); + } + + $this->removeObjectCustomVars($prop, $this->parent); + $this->removeFromOverrideServiceVars($prop, $this->parent); + + $db->delete('director_property', Filter::where('uuid', $uuid->getBytes())); + $db->delete('director_property', Filter::where('parent_uuid', $uuid->getBytes())); + + $objects = ['host', 'service', 'notification', 'command', 'user']; + foreach ($objects as $object) { + $this->db->delete("icinga_{$object}_var", Filter::where('property_uuid', $uuid->getBytes())); + } + + $db->getDbAdapter()->commit(); + } + + /** + * Remove the deleted property's key from all hosts' _override_servicevars custom variable + * + * @param array $property The deleted property + * @param array $parent The parent property (empty for root properties) + * + * @return void + */ + private function removeFromOverrideServiceVars(array $property, array $parent): void + { + $db = $this->db->getDbAdapter(); + + // Get the configured override varname, falling back to the default + $overrideVarname = $db->fetchOne( + $db->select() + ->from('director_setting', ['setting_value']) + ->where('setting_name = ?', 'override_services_varname') + ) ?: '_override_servicevars'; + + // Determine the root property key, root type, and path within each service's root-key value + if (empty($parent)) { + // Root property deleted: remove its key_name from each service's override vars + $rootKeyName = $property['key_name']; + $rootType = $property['value_type']; + $pathWithinRootValue = null; + } elseif ($parent['parent_uuid'] === null) { + // Child field of a root property deleted + $rootKeyName = $parent['key_name']; + $rootType = $parent['value_type']; + $pathWithinRootValue = [$property['key_name']]; + } else { + // Nested child field deleted (grandparent is the root property) + $rootProp = $this->fetchProperty(Uuid::fromBytes($parent['parent_uuid'])); + $rootKeyName = $rootProp['key_name']; + $rootType = $rootProp['value_type']; + $pathWithinRootValue = [$parent['key_name'], $property['key_name']]; + } + + // Fetch all hosts that have the _override_servicevars custom variable + $query = $db->select() + ->from('icinga_host_var', ['host_id', 'varvalue']) + ->where('varname = ?', $overrideVarname); + + $rows = $db->fetchAll($query, [], Zend_Db::FETCH_ASSOC); + + foreach ($rows as $row) { + $overrideVars = json_decode($row['varvalue'], true); + if (! is_array($overrideVars)) { + continue; + } + + $modified = false; + foreach ($overrideVars as $serviceName => $serviceVars) { + if (! is_array($serviceVars) || ! array_key_exists($rootKeyName, $serviceVars)) { + continue; + } + + $modified = true; + + if ($pathWithinRootValue === null) { + // Root property deleted: remove its key from the service's override vars + unset($serviceVars[$rootKeyName]); + } elseif ($rootType === 'dynamic-dictionary') { + // Dynamic dictionary: remove the path from every dynamic entry + if (is_array($serviceVars[$rootKeyName])) { + foreach ($serviceVars[$rootKeyName] as $entryKey => $entryValue) { + if (! is_array($entryValue)) { + continue; + } + + $this->removeDictionaryItem($serviceVars[$rootKeyName][$entryKey], $pathWithinRootValue); + if (empty($serviceVars[$rootKeyName][$entryKey])) { + unset($serviceVars[$rootKeyName][$entryKey]); + } + } + } + + if (empty($serviceVars[$rootKeyName])) { + unset($serviceVars[$rootKeyName]); + } + } else { + // Fixed/static type: remove the nested path within the root key's value + $this->removeDictionaryItem($serviceVars[$rootKeyName], $pathWithinRootValue); + if (empty($serviceVars[$rootKeyName])) { + unset($serviceVars[$rootKeyName]); + } + } + + if (empty($serviceVars)) { + unset($overrideVars[$serviceName]); + } else { + $overrideVars[$serviceName] = $serviceVars; + } + } + + if (! $modified) { + continue; + } + + if (empty($overrideVars)) { + $db->delete('icinga_host_var', [ + 'host_id = ?' => $row['host_id'], + 'varname = ?' => $overrideVarname, + ]); + } else { + $db->update( + 'icinga_host_var', + ['varvalue' => json_encode($overrideVars)], + [ + 'host_id = ?' => $row['host_id'], + 'varname = ?' => $overrideVarname, + ] + ); + } + } + } + + private function removeObjectCustomVars(array $property, ?array $parent = null): void + { + if (empty($parent)) { + return; + } + + $db = $this->db->getDbAdapter(); + + if ($parent['parent_uuid'] !== null) { + // Parent is itself a field — grandparent is the root property + $rootUuid = Uuid::fromBytes($parent['parent_uuid']); + $rootProp = $this->fetchProperty($rootUuid); + $rootType = $rootProp['value_type']; + } else { + $rootUuid = Uuid::fromBytes($parent['uuid']); + $rootType = $parent['value_type']; + } + + // Path within the stored JSON to the key being deleted — constant for all rows + $path = [$property['key_name']]; + if ($parent['parent_uuid'] !== null) { + array_unshift($path, $parent['key_name']); + } + + // Re-index the fixed-array items in director_property once, before processing stored vars + $isParentFixedArray = $parent['value_type'] === 'fixed-array'; + $isRootFixedArray = $rootType === 'fixed-array'; + if ($isParentFixedArray) { + $this->updateFixedArrayItems(Uuid::fromBytes($parent['uuid'])); + } elseif ($isRootFixedArray) { + $this->updateFixedArrayItems($rootUuid); + } + + foreach (['host', 'service', 'notification', 'command', 'user'] as $objectType) { + $idColumn = "{$objectType}_id"; + $varRows = $db->fetchAll( + $db->select() + ->from(['iov' => "icinga_{$objectType}_var"], []) + ->columns([$idColumn, 'varname', 'varvalue']) + ->where('property_uuid = ?', $rootUuid->getBytes()), + [], + Zend_Db::FETCH_ASSOC + ); + + $objectClass = DbObjectTypeRegistry::classByType($objectType); + + foreach ($varRows as $varRow) { + $varValue = json_decode($varRow['varvalue'], true); + + if ($rootType !== 'dynamic-dictionary') { + $this->removeDictionaryItem($varValue, $path); + } else { + foreach ($varValue as $entryKey => $entryValue) { + $varValue[$entryKey] = (object) $entryValue; + $this->removeDictionaryItem($varValue, $path); + } + } + + $object = $objectClass::loadWithAutoIncId($varRow[$idColumn], $this->db); + $vars = $object->vars(); + + if (empty($varValue)) { + $vars->set($varRow['varname'], null); + + continue; + } + + if ($isParentFixedArray) { + $varValue[$parent['key_name']] = array_values($varValue[$parent['key_name']]); + } elseif ($isRootFixedArray) { + $varValue = array_values($varValue); + } + + $vars->set($varRow['varname'], $varValue); + $vars->storeToDb($object); + } + } + } + + /** + * 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'], []) + ->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..f49e10f1e --- /dev/null +++ b/application/forms/DictionaryElements/Dictionary.php @@ -0,0 +1,210 @@ + + */ +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->items = $items; + + parent::__construct($name, $attributes); + } + + public function setAllowItemRemoval(bool $allow = false): static + { + $this->allowItemRemoval = $allow; + + return $this; + } + + protected function assemble(): void + { + $expectedCount = (int) $this->getPopulatedValue('item-count', 0); + $count = 0; + + $removedItems = []; + if ($this->allowItemRemoval) { + $removedItems = Session::getSession()->getNamespace('director.variables')->get('removed-properties', []); + while ($count < $expectedCount) { + $remove = $this->createElement( + 'submitButton', + 'remove_' . $count, + [ + 'label' => 'Remove Item', + 'class' => ['remove-property'], + 'formnovalidate' => true + ] + ); + + $this->registerElement($remove); + if ($remove->hasBeenPressed()) { + $removedValue = $this->getPopulatedValue($count); + $clearedItemName = null; + $session = Session::getSession()->getNamespace('director.variables'); + if (isset($removedValue['name'])) { + $clearedItemName = $removedValue['name']; + $addedProperties = $session->get('added-properties'); + + if (! empty($addedProperties) && isset($addedProperties[$clearedItemName])) { + unset($addedProperties[$clearedItemName]); + unset($this->items[$clearedItemName]); + $session->set('added-properties', $addedProperties); + } + + if (isset($this->items[$clearedItemName])) { + $removedItems[$clearedItemName] = $this->items[$clearedItemName]['uuid']; + } + } + + $this->clearPopulatedValue('items_removed'); + $this->clearPopulatedValue($remove->getName()); + $this->clearPopulatedValue($count); + $session->set('removed-properties', $removedItems); + $this->populate(['items_removed' => implode(', ', array_keys($removedItems))]); + + // Re-index populated values to ensure proper association with form data + foreach (range($count + 1, $expectedCount) as $i) { + $this->populate([$i - 1 => $this->getPopulatedValue($i) ?? []]); + } + } + + $count++; + } + } + + $addedItems = []; + foreach ($this->items as $key => $item) { + if (array_key_exists($key, $removedItems)) { + unset($this->items[$key]); + } elseif (isset($item['new'])) { + $addedItems[] = $key; + } + } + + $this->addElement('hidden', 'items_removed', ['value' => implode(', ', array_keys($removedItems))]); + $this->addElement('hidden', 'items_added', ['value' => implode(', ', $addedItems)]); + $count = 0; + foreach ($this->items as $item) { + $item['uuid'] = DbUtil::binaryResult($item['uuid']); + if (isset($item['parent_uuid'])) { + $item['parent_uuid'] = DbUtil::binaryResult($item['parent_uuid']); + } + + $element = new DictionaryItem($count, $item); + + // Only allow removal of items if the dictionary allows it and the item allows it + if ( + $this->allowItemRemoval + && $item['allow_removal'] + && $this->hasElement('remove_' . $count) + ) { + $element->setRemoveButton($this->getElement('remove_' . $count)); + } + + $this->addElement($element); + $count++; + } + + $this->clearPopulatedValue('item-count'); + $this->addElement('hidden', 'item-count', ['ignore' => true, 'value' => $count]); + if ($count === 0) { + if ($this->allowItemRemoval) { + $message = $this->translate('All custom properties in the object has been removed'); + } else { + $message = $this->translate('No fields configured'); + } + + $this->addHtml(new EmptyStateBar($message)); + } + } + + /** + * Prepare the dictionary for display + * + * @param array $items + * + * @return array + */ + public static function prepare(array $items): array + { + $values = []; + foreach ($items as $item) { + if (isset($item['removed'])) { + continue; + } + + $values[] = DictionaryItem::prepare($item); + } + + return $values; + } + + public function populate($values): static + { + if (! isset($values['item-count'])) { + $values['item-count'] = count($values); + } + + return parent::populate($values); + } + + /** + * Get the items to remove from the dictionary + * + * @return array + */ + public function getItemsToRemove(): array + { + $this->ensureAssembled(); + $itemsToRemove = $this->getPopulatedValue('items_removed'); + if (! empty($itemsToRemove)) { + $itemsToRemove = explode(', ', $itemsToRemove); + } else { + $itemsToRemove = []; + } + + 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..977b9e60f --- /dev/null +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -0,0 +1,482 @@ + ['no-border', 'dictionary-item']]; + + /** @var array Dictionary Item Fields */ + private $fields; + + /** @var ?FormElement Remove button */ + private ?FormElement $removeButton = null; + + public function __construct(string $name, array $items, $attributes = null) + { + $this->fields = $items; + + parent::__construct($name, $attributes); + } + + private static function fetchItemType(UuidInterface $uuid): string + { + $db = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); + $query = $db->select() + ->from( + ['dp' => 'director_property'], + ['value_type' => 'dp.value_type'] + ) + ->where('dp.parent_uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + return $db->fetchOne($query); + } + + /** + * Fetch datalist entries for a given property uuid. + * + * @param UuidInterface $uuid + * + * @return array + */ + private static function fetchDataListEntries(UuidInterface $uuid): array + { + $db = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); + $query = $db->select() + ->from( + ['dle' => 'director_datalist_entry'], + ['entry_name' => 'dle.entry_name', 'entry_value' => 'dle.entry_value'] + ) + ->join(['dl' => 'director_datalist'], 'dl.id = dle.list_id', []) + ->join(['dpl' => 'director_property_datalist'], 'dl.uuid = dpl.list_uuid', []) + ->where('dpl.property_uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + return $db->fetchPairs($query); + } + + 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['label'] ?? '']); + $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 ($this->removeButton !== null) { + $this->addAttributes(['class' => ['removable']]); + $this->addHtml(new HtmlElement( + 'div', + null, + $this->removeButton + )); + } + + if ($label === null) { + $label = $this->getElement('name')->getValue(); + } + + $uuid = Uuid::fromBytes($this->fields['uuid']); + $children = static::fetchChildrenItems( + $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', + 'class' => 'autosubmit' + ] + ); + } elseif ($type == 'bool') { + $this->addElement( + new IplBoolean( + $valElementName, + ['label' => $label, 'placeholder' => $placeholder, 'class' => 'autosubmit'] + ) + ); + } elseif ($type === 'dynamic-array') { + $this->addElement((new ArrayElement($valElementName)) + ->shouldAutoSubmit() + ->setVerticalTermDirection() + ->setPlaceHolder($placeholder) + ->setLabel($label . ' (Array)')); + } elseif (str_starts_with($type, 'datalist-')) { + $isStrict = substr($type, strlen('datalist-')) === 'strict'; + $itemType = self::fetchItemType($uuid); + $datalistEntries = self::fetchDataListEntries($uuid); + if ($itemType === 'string') { + if ($isStrict) { + $this->addElement( + 'select', + $valElementName, + [ + 'label' => $label . ' (Datalist String [strict])', + 'placeholder' => $placeholder, + 'value' => '', + 'options' => ['' => $this->translate('- Please choose -')] + + $datalistEntries + ] + ); + } else { + $fieldsetName = $this->getName(); + $listEntriesInput = $this->createElement('text', $valElementName, [ + 'autocomplete' => 'off', + 'ignore' => true, + 'label' => $label . ' (Datalist String [non-strict])', + 'data-enrichment-type' => 'completion', + 'data-auto-submit' => true, + 'data-term-suggestions' => "#{$valElementName}-suggestions-{$fieldsetName}", + 'data-suggest-url' => Url::fromPath('director/suggestions/datalist-entry', [ + 'uuid' => Uuid::fromBytes($this->fields['uuid'])->toString(), + 'showCompact' => true, + '_disableLayout' => true + ]) + ]); + + $fieldset = new HtmlElement('fieldset'); + $this->registerElement($listEntriesInput); + $searchInput = $this->createElement('hidden', "{$valElementName}-search", ['ignore' => true]); + $this->registerElement($searchInput); + $fieldset->addHtml($searchInput); + $labelInput = $this->createElement('hidden', "{$valElementName}-label", ['ignore' => true]); + $this->registerElement($labelInput); + $fieldset->addHtml($labelInput); + + $this->decorate($listEntriesInput); + + $fieldset->addHtml( + $listEntriesInput, + new HtmlElement('div', Attributes::create([ + 'id' => "{$valElementName}-suggestions-{$fieldsetName}", + 'class' => 'search-suggestions' + ])) + ); + + $this->addHtml($fieldset); + } + } elseif ($itemType === 'dynamic-array') { + $listEntriesInput = (new ArrayElement($valElementName)) + ->shouldAutoSubmit() + ->setSuggestedValues($datalistEntries) + ->setVerticalTermDirection() + ->setSuggestionUrl(Url::fromPath('director/suggestions/datalist-entry', [ + 'uuid' => Uuid::fromBytes($this->fields['uuid'])->toString(), + 'showCompact' => true, + '_disableLayout' => true + ])); + + if ($isStrict) { + $termValidator = function (array $terms) use ($datalistEntries) { + (new DatalistEntryValidator()) + ->setDatalistEntries($datalistEntries) + ->isValid($terms); + }; + + $listEntriesInput + ->setLabel($label . ' (Datalist Array [strict])') + ->on(TermInput::ON_ENRICH, $termValidator) + ->on(TermInput::ON_ADD, $termValidator) + ->on(TermInput::ON_PASTE, $termValidator) + ->on(TermInput::ON_SAVE, $termValidator); + } else { + $listEntriesInput->setLabel($label . ' (Datalist Array [non-strict])'); + } + + $this->addElement($listEntriesInput); + } + } 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, 'class' => 'autosubmit' + ] + ); + } + } + + public function populate($values): void + { + if ( + $values['type'] === 'datalist-non-strict' + && self::fetchItemType(Uuid::fromBytes($this->fields['uuid'])) === 'string' + ) { + $datalistEntries = array_flip(self::fetchDataListEntries(Uuid::fromBytes($this->fields['uuid']))); + + if (isset($datalistEntries[$values['var']])) { + $values['var-search'] = $datalistEntries[$values['var']]; + $values['var-label'] = $values['var']; + } else { + $values['var-search'] = $values['var']; + } + } + + parent::populate($values); + } + + /** + * 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'] ?? '' + ]; + + $property['uuid'] = Dbutil::binaryResult($property['uuid'] ?? ''); + + if ( + $property['value_type'] === 'dynamic-array' + || ( + in_array($property['value_type'], ['datalist-strict', 'datalist-non-strict'], true) + && self::fetchItemType(Uuid::fromBytes($property['uuid'])) === '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'] ?? ''; + } elseif ( + $property['value_type'] === 'datalist-non-strict' + && self::fetchItemType(Uuid::fromBytes($property['uuid'])) === 'string' + ) { + $dataListEntries = self::fetchDataListEntries(Uuid::fromBytes($property['uuid'])); + $value = $property['value'] ?? ''; + if (isset($dataListEntries[$value])) { + $values['var'] = $dataListEntries[$value]; + $values['var-search'] = $value; + $values['var-label'] = $dataListEntries[$value]; + } else { + $values['var'] = $value; + $values['var-search'] = $value; + } + } 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 + */ + private 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 = ?', Db\DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $db)) + ->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); + foreach ($propertyItems as $key => $propertyItem) { + $propertyItem['uuid'] = DbUtil::binaryResult($propertyItem['uuid']); + $propertyItem['parent_uuid'] = DbUtil::binaryResult($propertyItem['parent_uuid']); + $propertyItems[$key] = $propertyItem; + } + + 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; + } + + /** + * Set the remove button. + * + * @param ?FormElement $removeButton + * + * @return $this + */ + public function setRemoveButton(?FormElement $removeButton): static + { + $this->removeButton = $removeButton; + + return $this; + } + + /** + * 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); + } + } elseif ( + $this->getElement('type')->getValue() === 'datalist-non-strict' + && self::fetchItemType(Uuid::fromBytes($this->fields['uuid'])) === 'string' + ) { + $values['value'] = $this->getElement('var-search')->getValue(); + } 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..7fe504e47 --- /dev/null +++ b/application/forms/DictionaryElements/NestedDictionary.php @@ -0,0 +1,183 @@ + ['nested-dictionary', 'nested-fieldset']]; + + public const UNDEFINED_KEY = '__undefined__'; + + /** @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_item', [ + 'label' => $this->translate('Add Item'), + 'class' => ['add-item'], + '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[self::UNDEFINED_KEY . $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..599812b34 --- /dev/null +++ b/application/forms/DictionaryElements/NestedDictionaryItem.php @@ -0,0 +1,135 @@ + ['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, + 'class' => 'autosubmit' + ]); + + $id = $this->getPopulatedValue('id'); + if ($id === null) { + $id = uniqid('id-'); + } + + $this->addElement('hidden', 'id', ['value' => $id]); + $this->getAttributes()->set('id', $id); + + $label = $this->getElement('key')->getValue(); + if ($label === null) { + $label = $this->translate('New Item'); + } + + $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'])); + } + + /** + * 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; + } + + if (isset($property['key']) && str_starts_with($property['key'], NestedDictionary::UNDEFINED_KEY)) { + $property['key'] = null; + } + + 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/HostServiceBlacklistForm.php b/application/forms/HostServiceBlacklistForm.php new file mode 100644 index 000000000..52c4de163 --- /dev/null +++ b/application/forms/HostServiceBlacklistForm.php @@ -0,0 +1,140 @@ + 'icinga-controls']; + + /** @var IcingaServiceSet Service set to which the service belongs */ + private $set; + + public function __construct( + protected DbConnection $db, + protected IcingaHost $host, + protected IcingaService $service + ) { + $this->addAttributes(Attributes::create(['class' => ['host-service-deactivate-form']])); + if (! $this->hasBeenBlacklisted()) { + $this->addAttributes(Attributes::create(['class' => ['active']])); + } else { + $this->addAttributes(Attributes::create(['class' => ['deactivated']])); + } + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $blacklisted = $this->hasBeenBlacklisted(); + $this->addElement('submit', 'submit', [ + 'label' => $blacklisted + ? $this->translate('Reactivate Service') + : $this->translate('Deactivate Service'), + 'class' => $blacklisted ? '' : 'btn-remove' + ]); + } + + protected function onSuccess(): void + { + if ($this->hasBeenBlacklisted()) { + if ($this->removeFromBlacklist()) { + Notification::success(sprintf( + $this->translate("Service '%s' on host '%s' has been reactivated"), + $this->service->getObjectName(), + $this->host->getObjectName() + )); + } + } else { + if ($this->blacklist()) { + Notification::success(sprintf( + $this->translate("Service '%s' on host '%s' has been deactivated"), + $this->service->getObjectName(), + $this->host->getObjectName() + )); + } + } + } + + /** + * Whether the service has been blacklisted or not + * + * @return bool + */ + public function hasBeenBlacklisted(): bool + { + if ($this->service === null) { + return false; + } + + if ($this->blackListed === false) { + // Safety check, branches + $hostId = $this->host->get('id'); + $serviceId = $this->service->get('id'); + if (! $hostId || ! $serviceId) { + return false; + } + + $db = $this->db->getDbAdapter(); + $this->blackListed = 1 === (int) $db->fetchOne( + $db->select()->from('icinga_host_service_blacklist', 'COUNT(*)') + ->where('host_id = ?', $hostId) + ->where('service_id = ?', $serviceId) + ); + } + + return $this->blackListed; + } + + /** + * Remove the service from blacklist for the host + * + * @return int + */ + protected function removeFromBlacklist(): int + { + $db = $this->db->getDbAdapter(); + $where = implode(' AND ', [ + $db->quoteInto('host_id = ?', $this->host->get('id')), + $db->quoteInto('service_id = ?', $this->service->get('id')), + ]); + + return $db->delete('icinga_host_service_blacklist', $where); + } + + /** + * Blacklist the service for the host + * + * @return int + * + * @throws Zend_Db_Adapter_Exception | IcingaException + */ + protected function blacklist(): int + { + $db = $this->db->getDbAdapter(); + $this->host->unsetOverriddenServiceVars($this->service->getObjectName())->store(); + + return $db->insert('icinga_host_service_blacklist', [ + 'host_id' => $this->host->get('id'), + 'service_id' => $this->service->get('id') + ]); + } +} diff --git a/application/forms/IcingaHostDictionaryMemberForm.php b/application/forms/IcingaHostDictionaryMemberForm.php new file mode 100644 index 000000000..b9c4f6e8e --- /dev/null +++ b/application/forms/IcingaHostDictionaryMemberForm.php @@ -0,0 +1,55 @@ +addHidden('object_type', 'object'); + $this->addElement('text', 'object_name', [ + 'label' => $this->translate('Name'), + 'required' => !$this->object()->isApplyRule(), + 'description' => $this->translate( + 'Name for the instance you are going to create' + ) + ]); + $this->groupMainProperties()->setButtons(); + } + + protected function isNew() + { + return $this->object === null; + } + + protected function deleteObject($object) + { + } + + protected function getObjectClassname() + { + return IcingaHost::class; + } + + public function succeeded() + { + return $this->succeeded; + } + + public function onSuccess() + { + $this->succeeded = true; + } +} diff --git a/application/forms/IcingaMultiEditForm.php b/application/forms/IcingaMultiEditForm.php index 7f0c0d11f..a2aa27180 100644 --- a/application/forms/IcingaMultiEditForm.php +++ b/application/forms/IcingaMultiEditForm.php @@ -47,12 +47,6 @@ public function pickElementsFrom(QuickForm $form, $properties) public function setup() { $object = $this->object; - - $loader = new IcingaObjectFieldLoader($object); - $loader->prepareElements($this); - $loader->addFieldsToForm($this); - $this->varNameMap = $loader->getNameMap(); - if ($form = $this->relatedForm) { if ($form instanceof DirectorObjectForm) { $form->setDb($object->getConnection()) diff --git a/application/forms/IcingaServiceForm.php b/application/forms/IcingaServiceForm.php index 0993ef44f..55aeecb8f 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,9 +43,12 @@ class IcingaServiceForm extends DirectorObjectForm /** @var bool|null */ private $blacklisted; + /** @var ?IcingaHost */ private $blacklistedAncestor; + private $dictionaryUuidMap = []; + public function setApplyGenerated(IcingaService $applyGenerated) { $this->applyGenerated = $applyGenerated; @@ -671,6 +677,35 @@ 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]; + } + /** * @return $this * @throws \Zend_Form_Exception @@ -678,24 +713,20 @@ 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.' ) - )); + ]); } return $this; diff --git a/application/forms/ObjectCustomvarForm.php b/application/forms/ObjectCustomvarForm.php new file mode 100644 index 000000000..2d79a3790 --- /dev/null +++ b/application/forms/ObjectCustomvarForm.php @@ -0,0 +1,113 @@ +customVars = $this->getCustomVars(); + } + + public function getPropertyName(): string + { + $propertyUuid = $this->getValue('property'); + if ($propertyUuid) { + return $this->customVars[$propertyUuid] ?? ''; + } + + return ''; + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $propertyElement = $this->createElement( + 'select', + 'property', + [ + 'label' => $this->translate('Variable'), + 'required' => true, + 'class' => ['autosubmit'], + 'disabledOptions' => [''], + 'value' => '', + 'options' => array_merge( + ['' => $this->translate('Please choose a custom variable to add')], + $this->getCustomVars() + ) + ] + ); + + $this->addElement($propertyElement); + + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Add') + ]); + } + + protected function getCustomVars(): array + { + $parents = $this->object->listAncestorIds(); + $type = $this->object->getShortTableName(); + + $uuids = []; + $db = $this->db->getDbAdapter(); + $class = DbObjectTypeRegistry::classByType($type); + foreach ($parents as $parent) { + $uuids[] = $class::load($parent, $this->object->getConnection())->get('uuid'); + } + + $uuids[] = $this->object->get('uuid'); + $removedProperties = Session::getSession()->getNamespace('director.variables')->get('removed-properties', []); + + $query = $db + ->select() + ->from(['dp' => 'director_property'], ['uuid' => 'dp.uuid']) + ->join(['iop' => 'icinga_' . $type . '_property'], 'dp.uuid = iop.property_uuid', []) + ->where( + 'dp.parent_uuid IS NULL AND iop.' . $type . '_uuid IN (?)', + Dbutil::quoteBinaryCompat($uuids, $db) + ); + + if (! empty($removedProperties)) { + $query->where('dp.uuid NOT IN (?)', Dbutil::quoteBinaryCompat($removedProperties, $db)); + } + + $properties = $db->fetchAll( + $db->select()->from( + ['odp' => 'director_property'], + ['uuid' => 'odp.uuid', 'key_name' => 'odp.key_name'] + )->where('parent_uuid IS NULL AND odp.uuid NOT IN (?)', $query) + ->order('key_name') + ); + + $propUuidKeyPairs = []; + $alreadyAddedProperties = Session::getSession() + ->getNamespace('director.variables')->get('added-properties', []); + foreach ($properties as $property) { + if (! isset($alreadyAddedProperties[$property->key_name])) { + $uuid = DbUtil::binaryResult($property->uuid); + $propUuidKeyPairs[Uuid::fromBytes($uuid)->toString()] = $property->key_name; + } + } + + return $propUuidKeyPairs; + } +} diff --git a/application/forms/Validator/DatalistEntryValidator.php b/application/forms/Validator/DatalistEntryValidator.php new file mode 100644 index 000000000..141d13fbc --- /dev/null +++ b/application/forms/Validator/DatalistEntryValidator.php @@ -0,0 +1,51 @@ +datalistEntries = $datalistEntries; + + return $this; + } + + public function isValid($terms) + { + if ($this->datalistEntries === null) { + throw new LogicException( + 'Missing datalist entries. Cannot validate terms.' + ); + } + + if (! is_array($terms)) { + $terms = [$terms]; + } + + $isValid = true; + + foreach ($terms as $term) { + /** @var Term $term */ + $searchValue = $term->getSearchValue(); + if (! array_key_exists($searchValue, $this->datalistEntries)) { + $term->setMessage($this->translate('Value is not in the datalist.')); + + $isValid = false; + } else { + $term->setLabel($this->datalistEntries[$searchValue]); + } + } + + return $isValid; + } +} diff --git a/configuration.php b/configuration.php index f812f3c44..b560cb4af 100644 --- a/configuration.php +++ b/configuration.php @@ -175,3 +175,15 @@ ->setUrl('director/config/deployments') ->setPriority(902) ->setPermission(Permission::DEPLOYMENTS); +$section->add(N_('Custom Variables')) + ->setUrl('director/variables') + ->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/02-Installation.md.d/From-Source.md b/doc/02-Installation.md.d/From-Source.md index 93aecf79f..e9b92b73a 100644 --- a/doc/02-Installation.md.d/From-Source.md +++ b/doc/02-Installation.md.d/From-Source.md @@ -8,7 +8,7 @@ Make sure you use `director` as the module name. The following requirements must ## Requirements -* PHP (≥7.3) +* PHP (≥8.2) * Director v1.10 is the last version with support for PHP v5.6 * [Icinga 2](https://github.com/Icinga/icinga2) (≥2.8.0) * It is recommended to use the latest feature release of Icinga 2 diff --git a/doc/Dictionary-Support-Changes.md b/doc/Dictionary-Support-Changes.md new file mode 100644 index 000000000..4c63f4d1a --- /dev/null +++ b/doc/Dictionary-Support-Changes.md @@ -0,0 +1,799 @@ +# Dictionary Support & Enhanced Custom Variables — Branch Summary + +## Overview + +This branch introduces comprehensive **custom variable** support in Icinga Director, with a focus on structured variable types (dictionaries and arrays). It extends the existing data-fields model with a new first-class `Property` concept that supports rich, nested types and brings them into configuration baskets, REST API, and apply-for rules. + +--- + +## New Features + +### 1. Custom Variables Types + +A new `Custom Variables` section is available under the Icinga Director menu. Custom variables can be configured independently of data fields and support the following types: + +| Type | Description | +|-----------------------|-----------------------------------------------------------------------------------------------------------| +| `string` | Plain text value | +| `number` | Numeric value | +| `boolean` | True/false value | +| `fixed-array` | Ordered list with a pre-defined structure; values assigned to preconfigured positions | +| `datalist-strict` | Only values from the chosen datalist can be assigned, it can further be an array or string | +| `datalist-non-strict` | Values other than the values in the chosen datalist can be assigned, it can further be an array or string | +| `dynamic-dictionary` | Key-value map where each key maps to a structured sub-dictionary; keys are added by end-users | +| `dynamic-array` | Uniform array where end-users can add values freely | +| `fixed-dictionary` | Key-value map with a fixed set of preconfigured keys | +| `dynamic-dictionary` | Key-value map where each key maps to a structured sub-dictionary; keys are added by end-users | + +> Only one level of nesting is allowed: an inner dictionary may contain non-dictionary values only. +> For Fixed-array all the values must be provided in the object, you cannot leave out any of them. + +#### Type Examples (Infrastructure Monitoring Context) + +##### `string` +A plain text value. Useful for any single-value configuration parameter. + +``` +# On a host: the environment tag used to route alerts +vars.environment = "production" + +# On a service: the URL path to probe +vars.http_uri = "/api/health" + +# On a command: the path to the check plugin binary +vars.plugin_path = "/usr/lib/nagios/plugins/check_http" +``` + +##### `number` +A numeric value. Ideal for thresholds, timeouts, and retry counts. + +``` +# On a host: maximum check attempts before a hard state is raised +vars.max_check_attempts = 5 + +# On a service: SNMP polling interval in seconds +vars.snmp_timeout = 30 + +# On a notification: rate-limit delay in minutes between repeated alerts +vars.notification_interval = 60 +``` + +##### `boolean` +A true/false flag. Useful for feature toggles and conditional check behaviour. + +``` +# On a host: whether the host is behind a maintenance window by default +vars.in_maintenance = false + +# On a service: enable/disable SSL certificate verification +vars.ssl_verify = true + +# On a command: whether to follow HTTP redirects +vars.http_onredirect = true +``` + +##### `fixed-array` +An ordered list with a predefined structure. Each position has a fixed meaning configured in the property schema. The Icinga 2 config stores this as an array without keys. + +``` +# On a host: SSH arguments tuple [user, port, identity-file] +vars.ssh_args = ["monitoring", "22", "/etc/icinga2/ssh/id_rsa"] + +# On a service: positional thresholds for a custom check [warning, critical] +vars.disk_thresholds = ["20%", "10%"] +``` + +##### `datalist-strict` +The value must be one of the entries in a pre-configured Director data list. Can be stored as a single string or as an array of list values. Enforces a controlled vocabulary. + +``` +# On a host: data centre location, chosen from a "dc-locations" datalist +vars.datacenter = "eu-west-1" + +# On a notification: escalation tier, chosen from a "severity-levels" datalist +vars.escalation_tier = "critical" + +# As an array on a host: the teams that own this host, each value from +# a "teams" datalist +vars.owner_teams = ["networking", "platform"] +``` + +##### `datalist-non-strict` +Similar to `datalist-strict` but free-text values outside the data list are also accepted. Useful when the list provides common suggestions but operators occasionally need a custom entry. + +``` +# On a host: primary check zone — common zones come from a datalist, +# but a custom satellite zone name is also valid +vars.check_zone = "custom-satellite-eu3" + +# On a service: the responsible team; defaults come from a datalist +# but ad-hoc team names are permitted +vars.responsible_team = "database-infra-temp" +``` + +##### `fixed-dictionary` +A dictionary with a predefined, fixed set of keys. All keys are configured in the property schema; end-users only supply values. Good for structured connection parameters where the key set never changes. + +``` +# On a host: MySQL connection parameters +vars.mysql = { + host = "db-primary.internal" + port = "3306" + user = "icinga_monitor" + password = "s3cr3t" + database = "app_production" +} + +# On a service: SNMP v3 credentials (fixed set of keys) +vars.snmp_v3 = { + username = "monitoring" + auth_protocol = "SHA" + auth_password = "authpass123" + priv_protocol = "AES" + priv_password = "privpass456" +} +``` + +##### `dynamic-array` +A uniform array where end-users freely add values of the same type. Suitable for lists whose length varies per object. + +``` +# On a host: contact groups that should receive alerts for this host +vars.contact_groups = ["networking-ops", "on-call-primary", "noc"] + +# On a service: expected HTTP response strings (any of which satisfies the check) +vars.http_expect = ["HTTP/1.1 200", "HTTP/1.0 200"] + +# On a user: topics this user wants to receive notifications for +vars.notification_topics = ["disk", "cpu", "network"] +``` + +##### `dynamic-dictionary` +A dictionary where each top-level key is added freely by end-users, and the value for each key is a structured sub-dictionary with a preconfigured set of fields. Ideal for monitoring multiple similar resources on the same host (e.g. multiple disks, multiple virtual hosts). + +``` +# On a host: one entry per disk partition, each with threshold fields +vars.disk_checks += { + "/" = { + disk_partition = "/" + disk_wfree = "20%" + disk_cfree = "10%" + } + "/data" = { + disk_partition = "/data" + disk_wfree = "15%" + disk_cfree = "5%" + } +} + +# On a host: one entry per virtual host to probe via HTTP +vars.http_vhosts += { + "main-site" = { + http_address = "www.example.com" + http_uri = "/" + http_port = "443" + http_expect = ["HTTP/1.1 200"] + } + "api" = { + http_address = "api.example.com" + http_uri = "/health" + http_port = "443" + http_expect = ["HTTP/1.1 200", "HTTP/1.1 204"] + } +} +``` + +--- + +### 2. Enhanced Custom Variables UI + +- A dedicated **Custom Variables** tab is available on objects and templates. +- Users can click **Add Custom Variable** to attach configured custom variables to a template. +- Inherited custom variables from parent templates are shown and editable on objects importing those templates. +- Dynamic dictionary items are **appended** (using `+=`) instead of overwritten, so values from multiple templates are merged on the final object. + +--- + +### 3. Apply-For Rule Support for Dictionaries and Arrays + +- Apply-for rules now support **dynamic arrays** and **dynamic dictionaries**, not just flat arrays. +- Service apply rules can reference dictionary items from the host via `$value.$` syntax. +- The `config` keyword in apply-for rules has been renamed to `value` for clarity. +- The IcingaService form shows nested dictionary key suggestions as a list for use in apply-for configuration. + +#### Example: Apply-For using a Dynamic Array (`http_vhosts_list`) + +**Scenario:** A host has a `dynamic-array` variable `http_vhosts_list` listing the virtual host addresses to probe. A service apply rule creates one HTTP check per entry. + +**Host variable (on `web-server-01`):** +``` +vars.http_vhosts_list = [ + "www.example.com", + "api.example.com", + "status.example.com" +] +``` + +**Service apply rule (configured in Icinga Director):** + +- **Apply for:** `http_vhosts_list` (the array variable on the host) +- **Service name pattern:** `http - $item$` +- **check_command:** `http` + +**Generated Icinga 2 config:** +``` +apply Service "http - " for (item in host.vars.http_vhosts_list) { + check_command = "http" + vars.http_address = item + vars.http_port = 443 + vars.http_uri = "/" + assign where host.vars.http_vhosts_list +} +``` + +**Result:** Three services are created on `web-server-01`: +- `http - www.example.com` → checks `www.example.com` +- `http - api.example.com` → checks `api.example.com` +- `http - status.example.com` → checks `status.example.com` + +--- + +#### Example: Apply-For using a Dynamic Dictionary (`disk_checks`) + +**Scenario:** A host has a `dynamic-dictionary` variable `disk_checks` where each key is a disk label and the value is a structured sub-dictionary with threshold fields. A service apply rule creates one disk check per entry. + +**Host variable (on `linux-server-01`, merged from templates):** +``` +vars.disk_checks += { + "root" = { + disk_partition = "/" + disk_wfree = "20%" + disk_cfree = "10%" + } + "data" = { + disk_partition = "/data" + disk_wfree = "15%" + disk_cfree = "5%" + } + "backup" = { + disk_partition = "/mnt/backup" + disk_wfree = "10%" + disk_cfree = "5%" + } +} +``` + +**Service apply rule (configured in Icinga Director):** + +- **Apply for:** `disk_checks` (the dictionary variable on the host) +- **Service name pattern:** `disk - $key$` +- **check_command:** `disk` +- **Custom variables** referencing the sub-dictionary fields via `$value.$`: + +| Variable | Value | +|----------|-------| +| `disk_partitions` | `$value.disk_partition$` | +| `disk_wfree` | `$value.disk_wfree$` | +| `disk_cfree` | `$value.disk_cfree$` | + +> The hint text below the Custom Variables section in the apply-rule form shows which `$value.*$` fields are available for the selected dictionary. + +**Generated Icinga 2 config:** +``` +apply Service "disk - " for (key => value in host.vars.disk_checks) { + check_command = "disk" + vars.disk_partitions = value.disk_partition + vars.disk_wfree = value.disk_wfree + vars.disk_cfree = value.disk_cfree + assign where host.vars.disk_checks +} +``` + +**Result:** Three services are created on `linux-server-01`: +- `disk - root` → checks `/` with warn=20%, crit=10% +- `disk - data` → checks `/data` with warn=15%, crit=5% +- `disk - backup` → checks `/mnt/backup` with warn=10%, crit=5% + +Because `disk_checks` uses `+=`, a child template or the host itself can add more partitions without overwriting entries from the parent template. All entries from every level of the template tree are merged into the final host config. + +--- + +### 4. REST API Endpoint for updating Object Custom Variables + +A new REST API endpoint allows updating custom variables for an object directly: + +``` +PUT /director//variables? +``` + +| Object type | Query parameters | +|-------------|-----------------| +| Host | `name=` | +| Individual Service | `host=&name=` | +| Applied Service or Service Template | `name=` | +| User | `name=` | +| Notification | `name=` | +| Command | `name=` | + +The request body is a JSON object whose keys are variable names and values are the variable values. All standard types are accepted: strings, numbers, booleans, arrays, and nested dictionaries. +> Note: The configuration of the custom variables to be updated should exist in the database in `director_property` table before updating it via the REST API. Otherwise, the update will fail with a 404 Not Found error. + +--- + +#### Host — `linux-server-01` + +Updates disk check thresholds (dynamic-dictionary) and contact groups (dynamic-array): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/host/variables?name=linux-server-01' \ + -d '{ + "disk_checks": { + "root": { + "disk_partition": "/", + "disk_wfree": "20%", + "disk_cfree": "10%" + }, + "data": { + "disk_partition": "/data", + "disk_wfree": "15%", + "disk_cfree": "5%" + } + }, + "contact_groups": ["noc", "linux-ops"], + "environment": "production" + }' +``` + +--- + +#### Individual Service — `linux-server-01` / `http - www.example.com` + +Updates the HTTP check parameters (string, number, boolean, dynamic-array): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/service/variables?host=linux-server-01&name=http%20-%20www.example.com' \ + -d '{ + "http_address": "www.example.com", + "http_port": 443, + "http_uri": "/health", + "ssl_verify": true, + "http_expect": ["HTTP/1.1 200", "HTTP/1.0 200"] + }' +``` + +--- + +#### Service Template — `generic-http-service` + +Updates default SNMP v3 credentials on a service template (fixed-dictionary): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/service/variables?name=generic-http-service' \ + -d '{ + "snmp_v3": { + "username": "monitoring", + "auth_protocol": "SHA", + "auth_password": "newAuthPass!", + "priv_protocol": "AES", + "priv_password": "newPrivPass!" + }, + "snmp_timeout": 30 + }' +``` + +--- + +#### User — `on-call-engineer` + +Updates notification preferences (string, boolean, dynamic-array, fixed-dictionary): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/user/variables?name=on-call-engineer' \ + -d '{ + "pagerduty_key": "abc123def456", + "phone": "+1-555-0199", + "notify_on_recovery": true, + "notify_on_flapping": false, + "subscribed_services": ["disk", "http", "cpu", "memory"], + "working_hours": { + "timezone": "Europe/Berlin", + "start_time": "08:00", + "end_time": "18:00" + } + }' +``` + +--- + +#### Notification — `slack-host-notification` + +Updates Slack webhook details and escalation recipients (string, dynamic-array, fixed-dictionary): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/notification/variables?name=slack-host-notification' \ + -d '{ + "slack_webhook_url": "https://hooks.slack.com/services/T.../B.../newtoken", + "slack_channel": "#alerts-production", + "include_graphs": true, + "escalation_emails": ["noc@example.com", "oncall@example.com"], + "pagerduty": { + "integration_key": "xyz789", + "severity": "critical", + "component": "infrastructure", + "group": "platform" + } + }' +``` + +--- + +#### Command — `check_by_ssh` + +Updates default SSH connection parameters and plugin flags (string, number, boolean, fixed-array): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/command/variables?name=check_by_ssh' \ + -d '{ + "by_ssh_logname": "monitoring", + "by_ssh_port": 22, + "by_ssh_quiet": false, + "by_ssh_arguments": ["-w", "20", "-c", "10"] + }' +``` + +--- + +### 5. Configuration Basket Support + +- Configuration baskets now include custom properties when snapshotting **templates**. +- The snapshot captures both the `Template` (with `properties` UUIDs) and a new `Property` section containing the full property definitions and their nested items. +- Restoring a basket snapshot will restore the associated custom property schemas alongside the template. + +--- + +### 6. New `DirectorProperty` Object + +A new database-backed object `DirectorProperty` (`library/Director/Objects/DirectorProperty.php`) stores the custom property schema: + +- UUID-based identity +- Hierarchical structure via `parent_uuid` +- Supports all value types listed above +- Linked to templates via a join table + +--- + +## Database Changes + +A new migration (`schema/mysql-migrations/upgrade_192.sql`) and updated `schema/mysql.sql` introduce: + +- `director_property` — stores custom variable definitions (uuid, key_name, value_type, label, description, parent_uuid) +- `icinga__var` — extended to support property-based custom variables , where object type is `host`, `service`, `user`, `notification` or `command` +- Foreign key and index updates to support the hierarchical property model + +--- + +## UI & Frontend Changes + +- New **Custom Variables** tab on object/template detail views (`Web/Tabs/ObjectTabs.php`) +- New form classes: + - `CustomVariableForm` — create/edit a single custom variable on an object + - `CustomVariablesForm` — manage all custom variables for an object + - `DeleteCustomVariableForm` — remove a custom variable + - `DictionaryElements/Dictionary`, `DictionaryItem`, `NestedDictionary`, `NestedDictionaryItem` — composable form elements for rendering nested dictionary structures + - `ObjectCustomvarForm` — property selection and value assignment +- New CSS for custom variable forms, item lists, and collapsible dictionary entries (`public/css/custom-variables-form.less`, `item-list.less`, etc.) +- Icinga Web's native collapsible component is used for nested dictionary items +- `module.js` updated to suppress item count display on fieldset elements added by `CustomVariablesForm` + +--- + +## Backend / Library Changes + +| File | Change | +|-------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| +| `CustomVariables.php` | Extended to handle property-uuid-based lookup, `+=` operator for dictionaries, and inheritance merging | +| `CustomVariable.php` | New logic for serialising/deserialising typed variables | +| `CustomVariableString.php` | Explicit rendering of `$variable$`-style macros | +| `IcingaObject.php` | Updated to support new custom variables support | +| `IcingaService.php` | Apply-for rule support for dictionaries and arrays | +| `IcingaConfig / IcingaConfigHelper` | Expanded config macro matching (`$value.$` pattern) | +| `IcingaObjectHandler.php` | REST API create/update flow: object is created first, then custom variables are set | +| `BasketSnapshot.php` / `BasketSnapshotCustomVariableResolver.php` | Snapshot serialisation and restore for custom variables | +| `ObjectController.php` | Custom variables tab and CRUD actions | +| `TemplateTree.php` | Considers custom variable inheritance across template hierarchies | +| `CustomVariableReferenceLoader.php` | New helper to load custom variable references for basket/export flows | + +--- + +## IcingaDB Custom Variable Renderer + +`ProvidedHook/Icingadb/CustomVarRenderer.php` has been extended to support the new custom variable support in the IcingaDB web views. + +--- + +## CLI Commands + +| Command | Description | +|---------|----------------------------------------------------------------------------------------------------------------------------------------| +| `MigrateCommand` | Migration script to help transition existing string/number/bool data fields without data categories to the new custom properties model | +| `ExportCommand` | Updated to include custom variable export | + +### `director migrate datafields` + +Migrates existing Director data fields to the new custom properties model. Only fields that meet **all** of the following criteria are migrated: + +- Data type is one of: `String`, `Number`, `Boolean`, `Array`, `Datalist` +- The field has **no category** (`category_id IS NULL`) +- The field has **no duplicate variable name** (only one field per `varname`) +- The field is **not protected** (no `visibility = hidden` setting) +- No custom property with the same `key_name` already exists + +Fields that do not meet these criteria are skipped and reported when `--verbose` is used. + +#### Data type mapping + +| Data field type | Custom property `value_type` | +|-----------------|------------------------------| +| `DataTypeString` | `string` | +| `DataTypeNumber` | `number` | +| `DataTypeBoolean` | `boolean` | +| `DataTypeArray` | `dynamic-array` (with a `string` child item) | +| `DataTypeDatalist` (strict/suggest\_strict) | `datalist-strict` | +| `DataTypeDatalist` (other) | `datalist-non-strict` | + +After creating the custom property configurations, existing template bindings (host, service, notification, command, user) are carried over to the new `icinga__property` join table. + +#### Usage + +```bash +# Preview what would be migrated — no DB changes are made +icingacli director migrate datafields --dry-run + +# Preview with per-field detail +icingacli director migrate datafields --dry-run --verbose + +# Run the migration +icingacli director migrate datafields + +# Run with per-field progress output +icingacli director migrate datafields --verbose +``` + +#### Example dry-run output + +Suppose the Director instance has eight data fields. Two are duplicates, one belongs to a category, one is a hidden/protected string, one has a type that cannot be mapped (`DataTypeSqlQuery`), and the remaining three are plain `String`, `Number`, and `Array` fields ready to migrate. + +``` +$ icingacli director migrate datafields --dry-run --verbose + +The following datafield types and the corresponding number of datafields can be migrated: +Data type: String | count: 1 +Data type: Number | count: 1 +Data type: Array | count: 1 +Total datafields that can be migrated: 3 + +The following datafields can not be migrated as there are duplicates: +Var name: environment | count: 2 +Total datafields that can not be migrated because of having duplicates: 2 + +The following number of datafields belong to a category and can not be migrated: 1 + +The following number of datafields are protected and can not be migrated: 1 + +The following datafield types and the corresponding number of datafields can not be migrated: +Data type: SqlQuery | count: 1 +Total datafields that can not be migrated because of incompatible datatypes with new custom property support: 1 + +Number of datafields that can not be migrated as the custom properties with the same name already exists: 0 +Migrating Data fields +Migration completed +Summary: +Total datafields migrated: 0 +Total datafields skipped: 5 +``` + +> `--dry-run` prints the summary but does **not** write anything to the database. + +#### Example live migration output + +Running without `--dry-run` performs the migration inside a single transaction: + +``` +$ icingacli director migrate datafields --verbose + +Migrating Data fields +[-] Skipping migrating datafield 'environment' as there are '2' datafields with same name +[-] Skipping migrating datafield 'category_field' as it belongs to a category +[-] Skipping migrating datafield 'secret_password' as it is protected +[-] Skipping migration of datafield 'custom_sql_query' as it has an unsupported datatype 'SqlQuery' +[+] Datafield 'max_check_attempts' successfully migrated +[+] Datafield 'agent_enabled' successfully migrated +[+] Datafield 'contact_groups' successfully migrated +Migration completed +Summary: +Total datafields migrated: 3 +Total datafields skipped: 5 +``` + +After a successful run, the three fields appear as custom properties in `director_property` and their template assignments are reflected in the `icinga__property` tables. + +--- + +## Custom Variables by Object Type + +The following examples show how the new custom variable types can be applied across each supported Icinga 2 object type. + +### Host + +Hosts are the primary target for the new system. All variable types are fully supported, and the dynamic-dictionary merge behaviour is most relevant here (values from multiple imported templates are combined). + +``` +# generic-linux-host template +vars.environment = "production" # string +vars.max_check_retries = 3 # number +vars.agent_enabled = true # boolean +vars.ssh_args = ["monitoring", "22"] # fixed-array +vars.contact_groups = ["noc", "linux-ops"] # dynamic-array + +# Per-disk monitoring (dynamic-dictionary, merged across templates) +vars.disk_checks += { + "/" = { + disk_partition = "/" + disk_wfree = "20%" + disk_cfree = "10%" + } +} + +# MySQL credentials for the DB host template (fixed-dictionary) +vars.mysql_conn = { + host = "localhost" + port = "3306" + user = "icinga" + password = "secret" + database = "prod" +} +``` + +--- + +### Service + +Service custom variables drive check plugin arguments. Fixed types work well for connection parameters; dynamic arrays capture lists of expected strings; `string` and `number` types cover thresholds. + +``` +# generic-http-service template +vars.http_address = "www.example.com" # string +vars.http_port = 443 # number +vars.ssl_verify = true # boolean +vars.http_expect = ["HTTP/1.1 200"] # dynamic-array + +# HTTP virtual-host probes assigned per service (dynamic-dictionary) +vars.http_vhosts += { + "main" = { + http_address = "www.example.com" + http_uri = "/" + http_port = "443" + } +} + +# SNMP v3 credentials for an SNMP service (fixed-dictionary) +vars.snmp_v3 = { + username = "monitoring" + auth_protocol = "SHA" + auth_password = "authpass" + priv_protocol = "AES" + priv_password = "privpass" +} + +# SSH-based check arguments (fixed-array: [user, port, identity-file]) +vars.ssh_args = ["monitoring", "22", "/etc/icinga2/ssh/id_rsa"] +``` + +**Apply-for rule:** A service apply rule iterates over `host.vars.disk_checks` and maps each disk entry to a service. Dictionary fields are referenced as `$value.disk_partition$`, `$value.disk_wfree$`, etc. + +--- + +### User + +User objects benefit from `string` (contact details), `boolean` (opt-in flags), `dynamic-array` (subscribed topics), and `datalist-strict` (controlled notification preferences). + +``` +# on-call-engineer user +vars.pagerduty_key = "abc123def456" # string — PagerDuty integration key +vars.phone = "+1-555-0100" # string — SMS fallback number +vars.notify_on_recovery = true # boolean — send recovery notifications +vars.notify_on_flapping = false # boolean — suppress flapping alerts + +# Services this user wants to receive notifications for (dynamic-array) +vars.subscribed_services = ["disk", "http", "cpu", "memory"] + +# Preferred notification channels, from a "channels" datalist (datalist-strict) +vars.preferred_channel = "slack" + +# Working hours window (fixed-dictionary) +vars.working_hours = { + timezone = "Europe/Berlin" + start_time = "08:00" + end_time = "18:00" +} +``` + +--- + +### Command + +Command objects use custom variables to parameterise plugin invocations. `string` and `number` types set default argument values; `boolean` toggles flags; `fixed-array` passes positional arguments; `fixed-dictionary` groups related plugin options. + +``` +# check_by_ssh command +vars.by_ssh_logname = "monitoring" # string — SSH user +vars.by_ssh_port = 22 # number — SSH port +vars.by_ssh_quiet = false # boolean — suppress SSH banner + +# Positional arguments passed to the remote plugin (fixed-array) +vars.by_ssh_arguments = ["-w", "20", "-c", "10"] + +# SSL/TLS options for check_http (fixed-dictionary) +vars.http_ssl_opts = { + ssl_cert = "/etc/ssl/certs/ca-bundle.crt" + ssl_verify = "yes" + min_tls = "1.2" +} + +# Environments this command is valid in (datalist-strict, dynamic-array) +vars.valid_environments = ["production", "staging"] +``` + +--- + +### Notification + +Notification objects use custom variables to drive alert routing, templating, and channel selection. All scalar types apply; `fixed-dictionary` is useful for channel-specific config blocks; `dynamic-array` lists escalation recipients. + +``` +# slack-notification notification object +vars.slack_webhook_url = "https://hooks.slack.com/services/T.../B.../xxx" # string +vars.slack_channel = "#alerts-production" # string +vars.notification_icon = ":fire:" # string +vars.include_graphs = true # boolean +vars.retry_count = 3 # number + +# Escalation recipients (dynamic-array) +vars.escalation_emails = ["noc@example.com", "oncall@example.com"] + +# PagerDuty routing block (fixed-dictionary) +vars.pagerduty = { + integration_key = "abc123" + severity = "critical" + component = "infrastructure" + group = "platform" +} + +# Escalation tier, from a "severity-levels" datalist (datalist-strict) +vars.escalation_tier = "P1" +``` + +--- + +## Known Limitations / Not Yet Implemented + +- **No visibility control** — custom variable values (e.g. passwords) are always visible; no masking support. +- **Apply-for rules** only work with dynamic arrays and dynamic dictionaries, not fixed types. diff --git a/doc/custom-variables-demo.sh b/doc/custom-variables-demo.sh new file mode 100755 index 000000000..168344536 --- /dev/null +++ b/doc/custom-variables-demo.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# ============================================================================= +# Icinga Director — Custom Variable Support Demo +# ============================================================================= +# This script demonstrates the new structured custom variable support via the +# Director REST API. It covers all supported types, the apply-for rule pattern, +# and the datafields migration command. +# +# Prerequisites: +# - Icinga Director running at BASE_URL with the schema migration applied +# (schema/mysql-migrations/upgrade_192.sql) +# - Custom property schemas already created in the UI or via migration +# (Icinga Director → Custom Variables → Create Custom Variable) +# - jq installed (optional, for pretty-printing responses) +# ============================================================================= + +BASE_URL="http://localhost/icingaweb2" +CREDS="icingaadmin:icinga" +CURL="curl -k -s -u $CREDS -H 'Accept: application/json' -H 'Content-Type: application/json'" + +echo "=================================================================" +echo " Icinga Director — Custom Variable Support Demo" +echo "=================================================================" + +# --------------------------------------------------------------------------- +# SCENARIO 1: Host with scalar variables (string, number, boolean) +# --------------------------------------------------------------------------- +echo +echo "--- Scenario 1: Scalar custom variables on a host ---" +echo "Types: string, number, boolean" +echo +echo "Expected config fragment:" +cat <<'CONF' + vars.environment = "production" + vars.max_check_retries = 3 + vars.agent_enabled = true +CONF + +echo +echo "REST API call:" +cat <<'CMD' +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/host/variables?name=linux-host' \ + -d '{ + "environment": "production", + "max_check_retries": 3, + "agent_enabled": true + }' +CMD + +# Uncomment to run against a live instance: +# curl -k -u "$CREDS" \ +# -H 'Accept: application/json' -H 'Content-Type: application/json' \ +# -X PUT "$BASE_URL/director/host/variables?name=linux-server-01" \ +# -d '{"environment":"production","max_check_retries":3,"agent_enabled":true}' + + +# --------------------------------------------------------------------------- +# SCENARIO 2: Host with a dynamic-array (contact_groups) +# --------------------------------------------------------------------------- +echo +echo "--- Scenario 2: Dynamic-array custom variable ---" +echo "Variable: contact_groups (dynamic-array of strings)" +echo +echo "Expected config fragment:" +cat <<'CONF' + vars.contact_groups = ["noc", "linux-ops", "on-call-primary"] +CONF + +echo +echo "REST API call:" +cat <<'CMD' +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/host/variables?name=h4' \ + -d '{ + "environment": "production", + "max_check_retries": 3, + "agent_enabled": true, + "http_onredirect": false, + "new_linux_var": "foo bar", + "contact_groups": ["noc", "linux-ops", "on-call-primary"], + "mysql_conn": { + "host": "db-primary.internal", + "port": "3306", + "user": "icinga_monitor", + "password": "s3cr3t", + "database": "app_production" + }, + "disk_checks": { + "root": { "disk_partition": "/", "disk_wfree": "20%", "disk_cfree": "10%" }, + "data": { "disk_partition": "/data", "disk_wfree": "15%", "disk_cfree": "5%" }, + "backup": { "disk_partition": "/mnt/backup", "disk_wfree": "10%", "disk_cfree": "5%" } + } + }' +CMD + + +# --------------------------------------------------------------------------- +# SCENARIO 3: Host with a fixed-dictionary (mysql connection parameters) +# --------------------------------------------------------------------------- +echo +echo "--- Scenario 3: Fixed-dictionary custom variable ---" +echo "Variable: mysql_conn (fixed-dictionary with pre-defined keys)" +echo +echo "Expected config fragment:" +cat <<'CONF' + vars.mysql_conn = { + host = "db-primary.internal" + port = "3306" + user = "icinga_monitor" + password = "s3cr3t" + database = "app_production" + } +CONF + +echo +echo "REST API call:" +cat <<'CMD' +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/host/variables?name=linux-server-01' \ + -d '{ + "mysql_conn": { + "host": "db-primary.internal", + "port": "3306", + "user": "icinga_monitor", + "password": "s3cr3t", + "database": "app_production" + } + }' +CMD + + +# --------------------------------------------------------------------------- +# SCENARIO 4: Host with a dynamic-dictionary (disk_checks) + apply-for rule +# --------------------------------------------------------------------------- +echo +echo "--- Scenario 4: Dynamic-dictionary + apply-for rule ---" +echo "Variable: disk_checks (dynamic-dictionary; keys added per host)" +echo +echo "Step 4a — Set disk_checks on host 'linux-server-01':" +echo +echo "Expected config fragment:" +cat <<'CONF' + vars.disk_checks += { + "root" = { + disk_partition = "/" + disk_wfree = "20%" + disk_cfree = "10%" + } + "data" = { + disk_partition = "/data" + disk_wfree = "15%" + disk_cfree = "5%" + } + "backup" = { + disk_partition = "/mnt/backup" + disk_wfree = "10%" + disk_cfree = "5%" + } + } +CONF + +echo +echo "REST API call:" +cat <<'CMD' +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/host/variables?name=linux-server-01' \ + -d '{ + "disk_checks": { + "root": { "disk_partition": "/", "disk_wfree": "20%", "disk_cfree": "10%" }, + "data": { "disk_partition": "/data", "disk_wfree": "15%", "disk_cfree": "5%" }, + "backup": { "disk_partition": "/mnt/backup", "disk_wfree": "10%", "disk_cfree": "5%" } + } + }' +CMD + +echo +echo "Step 4b — Apply-for rule (configured in the Director UI):" +cat <<'CONF' + Apply for: disk_checks + Service name pattern: disk - $key$ + check_command: disk + + Custom variables in the apply rule: + disk_partitions → $value.disk_partition$ + disk_wfree → $value.disk_wfree$ + disk_cfree → $value.disk_cfree$ + + Hint: available $value.*$ fields are shown below the Custom Variables + section in the apply-rule form. +CONF + +echo +echo "Step 4c — Generated Icinga 2 config after deploy:" +cat <<'CONF' + apply Service "disk - " for (key => value in host.vars.disk_checks) { + check_command = "disk" + vars.disk_partitions = value.disk_partition + vars.disk_wfree = value.disk_wfree + vars.disk_cfree = value.disk_cfree + assign where host.vars.disk_checks + } +CONF + +echo +echo "Result: three services created on linux-server-01:" +echo " disk - root → checks / warn=20% crit=10%" +echo " disk - data → checks /data warn=15% crit=5%" +echo " disk - backup → checks /mnt/backup warn=10% crit=5%" + + +# --------------------------------------------------------------------------- +# SCENARIO 5: Dynamic-array apply-for rule (http_vhosts_list) +# --------------------------------------------------------------------------- +echo +echo "--- Scenario 5: Dynamic-array apply-for rule ---" +echo "Variable: http_vhosts_list (dynamic-array of strings)" +echo +echo "Set http_vhosts_list on host 'web-server-01':" +cat <<'CMD' +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/host/variables?name=web-host' \ + -d '{ + "http_vhosts_list": [ + "www.example.com", + "api.example.com", + "status.example.com" + ] + }' +CMD + +echo +echo "Generated Icinga 2 config after deploy:" +cat <<'CONF' + apply Service "http - " for (value in host.vars.http_vhosts_list) { + check_command = "http" + vars.http_address = value + vars.http_port = 443 + vars.http_uri = "/" + assign where host.vars.http_vhosts_list + } +CONF + +echo +echo "Result: three services on web-server-01:" +echo " http - www.example.com" +echo " http - api.example.com" +echo " http - status.example.com" + + +# --------------------------------------------------------------------------- +# SCENARIO 6: Other object types (service, user, notification, command) +# --------------------------------------------------------------------------- +echo +echo "--- Scenario 6: Custom variables on other object types ---" + +echo +echo "Service (individual) — http check parameters:" +cat <<'CMD' +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/service/variables?host=linux-server-01&name=http%20-%20www.example.com' \ + -d '{ + "http_address": "www.example.com", + "http_port": 443, + "http_uri": "/health", + "ssl_verify": true, + "http_expect": ["HTTP/1.1 200", "HTTP/1.0 200"] + }' +CMD + +echo +echo "User — notification preferences:" +cat <<'CMD' +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/user/variables?name=test' \ + -d '{ + "username": "abc123def456" + }' +CMD + +echo +echo "Command — SSH check parameters (includes fixed-array):" +cat <<'CMD' +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/command/variables?name=check_by_ssh' \ + -d '{ + "by_ssh_logname": "monitoring", + "by_ssh_port": 22, + "by_ssh_quiet": false, + "by_ssh_arguments": ["-w", "20", "-c", "10"] + }' +CMD + + +# --------------------------------------------------------------------------- +# SCENARIO 7: Datafields migration (CLI) +# --------------------------------------------------------------------------- +echo +echo "--- Scenario 7: Migrate existing data fields to custom properties ---" +echo +echo "Preview what would be migrated (no DB changes):" +cat <<'CMD' +icingacli director migrate datafields --dry-run --verbose +CMD + +echo +echo "Run the migration:" +cat <<'CMD' +icingacli director migrate datafields --verbose +CMD + +echo +cat <<'INFO' +Fields eligible for migration: + - Data type: String, Number, Boolean, Array, or Datalist + - No data category (category_id IS NULL) + - No duplicate varname + - Not protected (visibility != hidden) + - No existing custom property with the same key_name + +After migration, the fields appear as custom properties in director_property +and their template assignments are reflected in icinga__property tables. +INFO + + +# --------------------------------------------------------------------------- +echo +echo "=================================================================" +echo " Demo complete." +echo " See doc/Dictionary-Support-Changes.md for full documentation." +echo "=================================================================" diff --git a/library/Director/CustomVariable/CustomVariable.php b/library/Director/CustomVariable/CustomVariable.php index 4b5dd3e43..17acf55c9 100644 --- a/library/Director/CustomVariable/CustomVariable.php +++ b/library/Director/CustomVariable/CustomVariable.php @@ -4,15 +4,21 @@ use Exception; use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; 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; @@ -27,6 +33,8 @@ abstract class CustomVariable implements IcingaConfigRenderer protected $checksum; + protected $whiteList = []; + protected function __construct($key, $value = null) { $this->key = $key; @@ -88,6 +96,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 @@ -109,6 +135,13 @@ public function toConfigString($renderExpressions = false) )); } + public function setWhiteList(array $whiteList): self + { + $this->whiteList = $whiteList; + + return $this; + } + public function flatten(array &$flat, $prefix) { $flat[$prefix] = $this->getDbValue(); @@ -156,6 +189,11 @@ public function toConfigStringPrefetchable($renderExpressions = false) } } + public function getWhiteList(): array + { + return $this->whiteList; + } + public function setModified($modified = true) { $this->modified = $modified; @@ -240,7 +278,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 +304,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(DbUtil::binaryResult($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..e7bea4e7a 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(); @@ -119,6 +119,12 @@ public function getInternalValue($key) public function toConfigString($renderExpressions = false) { + if ($this->whiteList !== null) { + foreach ($this->value as $key => $value) { + $this->value[$key] = $value->setWhiteList($this->whiteList); + } + } + // TODO return c::renderDictionary($this->value); } diff --git a/library/Director/CustomVariable/CustomVariableString.php b/library/Director/CustomVariable/CustomVariableString.php index 2d509681c..a978b2c9f 100644 --- a/library/Director/CustomVariable/CustomVariableString.php +++ b/library/Director/CustomVariable/CustomVariableString.php @@ -46,7 +46,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()); } diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php index bb0b44b34..56a5b4397 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 ($uuid) { + $row['property_uuid'] = Db\DbUtil::quoteBinaryCompat($uuid, $db); + } + + $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'] = Db\DbUtil::quoteBinaryCompat($uuid, $db); + } + $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,53 @@ public function toLegacyConfigString() * * @return string */ - protected function renderSingleVar($key, $var, $renderExpressions = false) + protected function renderSingleVar($key, $var, ?IcingaObject $object = null, $renderExpressions = false) { + $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 +548,23 @@ 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 + { + if (isset($this->vars[$key])) { + $this->vars[$key]->setUuid($uuid); + } + + return $this; + } + public function __toString() { try { diff --git a/library/Director/Dashboard/Dashlet/CustomVariablesDashlet.php b/library/Director/Dashboard/Dashlet/CustomVariablesDashlet.php new file mode 100644 index 000000000..c31de491f --- /dev/null +++ b/library/Director/Dashboard/Dashlet/CustomVariablesDashlet.php @@ -0,0 +1,33 @@ +translate('Manage Custom Variables'); + } + + public function getSummary() + { + return $this->translate( + 'A new custom variable support to manage custom variables required for your configuration. ' + . 'Make sure they fits your rules' + ); + } + + public function getUrl() + { + return 'director/variables'; + } + + public function listRequiredPermissions() + { + return [Permission::ADMIN]; + } +} diff --git a/library/Director/Dashboard/Dashlet/DatafieldDashlet.php b/library/Director/Dashboard/Dashlet/DatafieldDashlet.php index a381a3fe1..d380f1319 100644 --- a/library/Director/Dashboard/Dashlet/DatafieldDashlet.php +++ b/library/Director/Dashboard/Dashlet/DatafieldDashlet.php @@ -16,7 +16,7 @@ public function getTitle() public function getSummary() { return $this->translate( - 'Data fields make sure that configuration fits your rules' + 'Data fields make sure that configuration fits your rules (Deprecated)' ); } diff --git a/library/Director/Dashboard/DataDashboard.php b/library/Director/Dashboard/DataDashboard.php index 36a807b29..635585861 100644 --- a/library/Director/Dashboard/DataDashboard.php +++ b/library/Director/Dashboard/DataDashboard.php @@ -5,9 +5,10 @@ class DataDashboard extends Dashboard { protected $dashletNames = [ - 'Datafield', + 'CustomVariables', 'DatafieldCategory', 'Datalist', + 'Datafield', 'Customvar' ]; diff --git a/library/Director/Data/CustomVariableReferenceLoader.php b/library/Director/Data/CustomVariableReferenceLoader.php new file mode 100644 index 000000000..33b74e8c7 --- /dev/null +++ b/library/Director/Data/CustomVariableReferenceLoader.php @@ -0,0 +1,58 @@ +db = $connection->getDbAdapter(); + } + + /** + * Load properties referenced by the object + * + * @param IcingaObject $object + * + * @return array + */ + public function loadFor(IcingaObject $object): array + { + $db = $this->db; + $uuid = Db\DbUtil::quoteBinaryCompat($object->get('uuid'), $db); + 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) { + $propUuid = Db\DbUtil::binaryResult($property->property_uuid); + $property->property_uuid = Uuid::fromBytes( + $propUuid + )->toString(); + $res[$key] = $property; + } + + return $res; + } +} diff --git a/library/Director/Data/Exporter.php b/library/Director/Data/Exporter.php index 1a3cfcb7d..586391cc4 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 CustomVariableReferenceLoader */ + 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 CustomVariableReferenceLoader($connection); } public function export(DbObject $object) @@ -75,7 +80,7 @@ public function export(DbObject $object) } if ($column = $object->getUuidColumn()) { if ($uuid = $object->get($column)) { - $props[$column] = Uuid::fromBytes($uuid)->toString(); + $props[$column] = Uuid::fromBytes(Db\DbUtil::binaryResult($uuid))->toString(); } } @@ -274,6 +279,13 @@ protected function exportDbObject(DbObject $object) $props['objects'] = JsonString::decode($props['objects']); } } + + if ($object instanceof DirectorProperty && $props['parent_uuid'] !== null) { + $props['parent_uuid'] = Uuid::fromBytes( + Db\DbUtil::binaryResult($props['parent_uuid']) + )->toString(); + } + unset($props['uuid']); // Not yet if (! $this->showDefaults) { foreach ($props as $key => $value) { @@ -296,6 +308,7 @@ protected function exportIcingaObject(IcingaObject $object) { $props = (array) $object->toPlainObject($this->resolveObjects, !$this->showDefaults); if ($object->supportsFields()) { + $props['properties'] = $this->propertyReferenceLoader->loadFor($object); $props['fields'] = $this->fieldReferenceLoader->loadFor($object); } diff --git a/library/Director/Data/ObjectImporter.php b/library/Director/Data/ObjectImporter.php index bd9f87c39..8032385e1 100644 --- a/library/Director/Data/ObjectImporter.php +++ b/library/Director/Data/ObjectImporter.php @@ -56,6 +56,8 @@ public function import(string $implementation, stdClass $plain): DbObject $properties = (array) $plain; unset($properties['fields']); unset($properties['originalId']); + 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/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..562034ffd 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 BasketSnapshotCustomVariableResolver */ + 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(): BasketSnapshotCustomVariableResolver + { + if ($this->customPropertyResolver === null) { + $this->customPropertyResolver = new BasketSnapshotCustomVariableResolver( + $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 96fb2d582..dba0d9f8b 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, + 'CustomVariable' => 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 BasketSnapshotCustomVariableResolver($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 BasketSnapshotCustomVariableResolver($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, + ?BasketSnapshotCustomVariableResolver $customPropertyResolver = null ) { if (isset($all->$typeName)) { $objects = (array) $all->$typeName; @@ -306,6 +334,11 @@ public function restoreType( // Linking fields right now, as we're not in $changed if ($new instanceof IcingaObject) { $fieldResolver->relinkObjectFields($new, $object); + + $customPropertyResolver?->relinkObjectCustomProperties( + $new, + $object + ); } } } else { @@ -313,6 +346,11 @@ public function restoreType( // been changed if ($new instanceof IcingaObject) { $fieldResolver->relinkObjectFields($new, $object); + + $customPropertyResolver?->relinkObjectCustomProperties( + $new, + $object + ); } } } @@ -326,6 +364,11 @@ public function restoreType( // un-stored, let's do it right here if ($new instanceof IcingaObject) { $fieldResolver->relinkObjectFields($new, $objects[$key]); + + $customPropertyResolver?->relinkObjectCustomProperties( + $new, + $objects[$key] + ); } } } diff --git a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php new file mode 100644 index 000000000..e763c9106 --- /dev/null +++ b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php @@ -0,0 +1,302 @@ +objects = $objects; + $this->targetDb = $targetDb; + } + + /** + * Load all custom properties from the DB. + * + * @param Db $db + * + * @return DirectorProperty[] + */ + public function loadCurrentProperties(Db $db): array + { + $properties = []; + foreach ($this->getRequiredUuids() as $uuid) { + $properties[$uuid] = DirectorProperty::loadWithUniqueId(Uuid::fromString($uuid), $db); + } + + return $properties; + } + + /** + * Store new custom properties. + * + * @return void + */ + public function storeNewProperties(): void + { + $this->targetProperties = null; // Clear Cache + foreach ($this->getTargetProperties() as $uuid => $property) { + if ($property->hasBeenModified()) { + $property->store(); + $this->uuidMap[$uuid] = Uuid::fromBytes( + DbUtil::binaryResult($property->get('uuid')) + )->toString(); + } + + $modified = $this->restoreCustomPropertyItems($property); + if ($modified && ! isset($this->uuidMap[$uuid])) { + $this->uuidMap[$uuid] = Uuid::fromBytes( + DbUtil::binaryResult($property->get('uuid')) + )->toString(); + } + } + } + + /** + * Relink custom properties to the new object. + * + * @param IcingaObject $new + * @param $object + */ + public function relinkObjectCustomProperties(IcingaObject $new, $object): void + { + if (! $new->supportsCustomProperties() || ! isset($object->properties)) { + return; + } + + $customPropertyMap = $this->getUuidMap(); + $db = $this->targetDb->getDbAdapter(); + $objectUuid = DbUtil::quoteBinaryCompat($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 + ) { + $propertyUuid = DbUtil::binaryResult($mapping->property_uuid); + $existingCustomProperties[Uuid::fromBytes($propertyUuid)->toString()] = $mapping; + } + + foreach ($object->properties as $property) { + $propertyUuid = DbUtil::binaryResult($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 => DbUtil::quoteBinaryCompat($new->get('uuid'), $db), + 'property_uuid' => DbUtil::quoteBinaryCompat(Uuid::fromString($uuid)->getBytes(), $db) + ]); + } + } + + $existingCustomPropertyUuids = array_keys($existingCustomProperties); + foreach ($existingCustomPropertyUuids as $idx => $uuid) { + $existingCustomPropertyUuids[$idx] = DbUtil::quoteBinaryCompat($uuid, $db); + } + + if (! empty($existingCustomProperties)) { + $db->delete( + $table, + $db->quoteInto( + "$objectKey = $objectUuid AND property_uuid IN (?)", + DbUtil::quoteBinaryCompat($existingCustomPropertyUuids, $db) + ) + ); + } + } + + /** + * For diff purposes only, gives '(UNKNOWN)' for custom properties missing + * in our DB + * + * @param object $object + */ + public function tweakTargetUuids(object $object): void + { + if (! isset($object->properties)) { + return; + } + + $forward = $this->getUuidMap(); + $map = array_flip($forward); + foreach ($object->properties as $property) { + if (! isset($property->property_uuid)) { + continue; + } + + $uuid = $property->property_uuid; + if (isset($map[$uuid])) { + $property->property_uuid = $map[$uuid]; + } else { + $property->property_uuid = '(UNKNOWN)'; + } + } + } + + /** + * Get all required UUIDs for custom properties. + * + * @return array + */ + protected function getRequiredUuids(): array + { + if ($this->requiredUuids !== null) { + return $this->requiredUuids; + } + + if (isset($this->objects['CustomVariable'])) { + $this->requiredUuids = array_keys($this->objects['CustomVariable']); + + return $this->requiredUuids; + } + + $uuids = []; + // Get the uuids of all custom properties associated with all the objects hosts, services, etc. + foreach ($this->objects as $objectType => $objects) { + if ( + ! in_array( + $objectType, + ['HostTemplate', 'ServiceTemplate', 'CommandTemplate', 'NotificationTemplate', 'UserTemplate'] + ) + ) { + continue; + } + + foreach ($objects as $object) { + if (! isset($object->properties)) { + continue; + } + + foreach ($object->properties as $property) { + $uuids[$property->property_uuid] = true; + } + } + } + + $this->requiredUuids = array_keys($uuids); + + return $this->requiredUuids; + } + + /** + * Get all objects of a certain type. + * + * @param $type + * + * @return object[] + */ + protected function getObjectsByType($type): array + { + if (! isset($this->objects->{$type})) { + return []; + } + + return (array) $this->objects->{$type}; + } + + /** + * Get all target properties. + * + * @return DirectorProperty[] + */ + protected function getTargetProperties(): array + { + if ($this->targetProperties === null) { + $this->calculateUuidMap(); + } + + return $this->targetProperties; + } + + /** + * Get the UUID map for object property UUIDs. + * + * @return array + */ + protected function getUuidMap(): array + { + if ($this->uuidMap === null) { + $this->calculateUuidMap(); + } + + return $this->uuidMap; + } + + /** + * Calculate the UUID map for object property UUIDs. + * + * @return void + */ + protected function calculateUuidMap(): void + { + $this->uuidMap = []; + $this->targetProperties = []; + foreach ($this->getObjectsByType('CustomVariable') as $uuid => $object) { + // Hint: import() doesn't store! + $new = DirectorProperty::import($object, $this->targetDb); + if ($new->hasBeenLoadedFromDb()) { + $newUuid = Uuid::fromBytes( + Db\DbUtil::binaryResult($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->fetchItemsFromDb() 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/IcingaConfig.php b/library/Director/IcingaConfig/IcingaConfig.php index a79bf3c79..00d7dbe8c 100644 --- a/library/Director/IcingaConfig/IcingaConfig.php +++ b/library/Director/IcingaConfig/IcingaConfig.php @@ -566,11 +566,18 @@ protected function renderHostOverridableVars() globals.directorWarnOnceForServiceWithoutHost() } + var overridenVar = name + if (vars.overridenVar) { + overridenVar = vars.overridenVar + } + if (vars) { - vars += host.vars[DirectorOverrideVars][name] + vars += host.vars[DirectorOverrideVars][overridenVar] } else { - vars = host.vars[DirectorOverrideVars][name] + vars = host.vars[DirectorOverrideVars][overridenVar] } + + vars.remove("overridenVar") } } ', diff --git a/library/Director/IcingaConfig/IcingaConfigHelper.php b/library/Director/IcingaConfig/IcingaConfigHelper.php index 5d44bfaee..2d87a6740 100644 --- a/library/Director/IcingaConfig/IcingaConfigHelper.php +++ b/library/Director/IcingaConfig/IcingaConfigHelper.php @@ -2,6 +2,7 @@ namespace Icinga\Module\Director\IcingaConfig; +use Icinga\Module\Director\CustomVariable\CustomVariableString; use InvalidArgumentException; use function ctype_digit; @@ -70,6 +71,10 @@ public static function renderKeyValue($key, $value, $prefix = ' ') public static function renderKeyOperatorValue($key, $operator, $value, $prefix = ' ') { + if ($value instanceof CustomVariableString && ! empty($value->getWhiteList())) { + $value = $value->toConfigString(true); + } + $string = sprintf( "%s %s %s", $key, @@ -375,13 +380,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 && $whiteList === null) { + return false; + } + + if (in_array($name, $whiteList ?? [], true)) { + 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 +434,15 @@ public static function renderStringWithVariables($string, ?array $whiteList = nu } 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/DirectorDatafield.php b/library/Director/Objects/DirectorDatafield.php index 65781d33a..c214189c8 100644 --- a/library/Director/Objects/DirectorDatafield.php +++ b/library/Director/Objects/DirectorDatafield.php @@ -2,8 +2,10 @@ namespace Icinga\Module\Director\Objects; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; use Icinga\Module\Director\Data\Db\DbObjectWithSettings; use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\DirectorObject\Automation\BasketSnapshotFieldResolver; use Icinga\Module\Director\DirectorObject\Automation\CompareBasketObject; use Icinga\Module\Director\Forms\IcingaServiceForm; @@ -222,7 +224,41 @@ public function getFormElement(DirectorObjectForm $form, $name = null): ?ZfEleme $el->setLabel($caption); } - if ($description = $this->get('description')) { + // TODO: Use $form->getObject() to check for custom properties in that specific object + $varName = $this->get('varname'); + $object = $form->getObject(); + $objectType = $form->getObject()->getShortTableName(); + $parents = $object->listAncestorIds(); + $db = $form->getDb(); + $uuids = []; + foreach ($parents as $parent) { + $class = DbObjectTypeRegistry::classByType($objectType); + $uuids[] = $class::loadWithAutoIncId($parent, $db)->get('uuid'); + } + + if ($object->get('uuid') && $object->isTemplate()) { + $uuids[] = $object->get('uuid'); + } + + $query = $db + ->select() + ->from(['dp' => 'director_property'], ['key_name' => 'dp.key_name']) + ->join(['iop' => "icinga_{$objectType}_property"], 'dp.uuid = iop.property_uuid', []) + ->where("iop.{$objectType}_uuid", DbUtil::quoteBinaryCompat($uuids, $db->getDbAdapter())) + ->where('parent_uuid IS NULL AND key_name', $varName); + + if ($query->fetchOne() !== false) { + $el->setAttrib('hidden', true); + $el->getDecorator('Description') + ->setOptions(['tag' => 'p', 'class' => ['description', 'deprecated-data-field']]); + $description = $form->translate( + 'There is a custom property with the same name. Go to "Custom Variables" tab to manage it.' + ); + } else { + $description = $this->get('description'); + } + + if ($description) { $el->setDescription($description); } diff --git a/library/Director/Objects/DirectorDatalist.php b/library/Director/Objects/DirectorDatalist.php index 637bc75a5..52a82915f 100644 --- a/library/Director/Objects/DirectorDatalist.php +++ b/library/Director/Objects/DirectorDatalist.php @@ -115,10 +115,23 @@ protected function isInUse(): bool ->where('datatype = ?', DataTypeDatalist::class) ->where('setting_value = ?', $id); + $customPropertiesCheck = $db->select() + ->from(['dp' => 'director_property'], ['key_name']) + ->join( + ['dpl' => 'director_property_datalist'], + 'dp.uuid = dpl.property_uuid', + [] + ) + ->where($db->quoteInto('dp.uuid = ?', $this->get('uuid'))); + if ($db->fetchOne($dataFieldsCheck)) { return true; } + if ($db->fetchOne($customPropertiesCheck)) { + return true; + } + $syncCheck = $db->select() ->from(['sp' => 'sync_property'], ['source_expression']) ->where('sp.destination_field = ?', 'list_id') diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php new file mode 100644 index 000000000..237ae2173 --- /dev/null +++ b/library/Director/Objects/DirectorProperty.php @@ -0,0 +1,415 @@ + null, + 'key_name' => null, + 'parent_uuid' => null, + 'category_id' => null, + 'value_type' => null, + 'label' => null, + 'description' => null + ]; + + protected $binaryProperties = [ + 'uuid', + 'parent_uuid' + ]; + + protected $relations = [ + 'category' => 'DirectorDatafieldCategory' + ]; + + /** @var DirectorProperty[] */ + private $items = []; + + /** @var ?DirectorDatalist */ + private $datalist = null; + + /** @var ?DirectorDatafieldCategory */ + private $category; + + protected function setDbProperties($properties) + { + unset($properties->parent_uuid_v); // hack to ignore virtual column, need a better solution + + return parent::setDbProperties($properties); + } + + public function setProperties($props) + { + unset($props['parent_uuid_v']); + + return parent::setProperties($props); + } + + /** + * Get category to which the property belongs to + * + * @return ?DirectorDatafieldCategory + * + * @throws NotFoundError + */ + public function getCategory(): ?DirectorDatafieldCategory + { + if ($this->category) { + return $this->category; + } + + if ($id = $this->get('category_id')) { + $this->category = DirectorDatafieldCategory::loadWithAutoIncId($id, $this->getConnection()); + + return $this->category; + } + + return null; + } + + /** + * Get the category name to which the property belongs to + * + * @return ?string + */ + public function getCategoryName(): ?string + { + $category = $this->getCategory(); + if ($category === null) { + return null; + } + + return $category->get('category_name'); + } + + /** + * Set the category to which the property belongs to + * + * @param DirectorDatafieldCategory|string|null $category + * + * @return void + */ + public function setCategory($category): void + { + if ($category === null) { + $this->category = null; + $this->set('category_id', null); + } elseif ($category instanceof DirectorDatafieldCategory) { + if ($category->hasBeenLoadedFromDb()) { + $this->set('category_id', $category->get('id')); + } + + $this->category = $category; + } else { + $category = DirectorDatafieldCategory::loadOptional($category, $this->getConnection()); + if ($category) { + $this->setCategory($category); + } else { + $this->setCategory(DirectorDatafieldCategory::create( + ['category_name' => $category], + $this->getConnection() + )); + } + } + } + + /** + * @throws NotFoundError + */ + public function export(): stdClass + { + $plain = (object) $this->getProperties(); + $db = $this->getDb(); + $uuid = Db\DbUtil::binaryResult($this->get('uuid')); + if ($uuid) { + $uuid = Uuid::fromBytes($uuid); + $plain->uuid = $uuid->toString(); + $plain->items = $this->exportChildren(); + + if (str_starts_with($plain->value_type, 'datalist-')) { + $query = $this->db->select()->from(['dd' => 'director_datalist'], ['list_name']) + ->join(['dpdl' => 'director_property_datalist'], 'dpdl.list_uuid = dd.uuid', []) + ->where($this->db->quoteInto( + 'dpdl.property_uuid = ?', + Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db) + )); + $plain->datalist = $this->db->fetchOne($query); + } + } + + if ($plain->parent_uuid !== null) { + $plain->parent_uuid = Uuid::fromBytes( + Db\DbUtil::binaryResult($plain->parent_uuid) + )->toString(); + } + + if (property_exists($plain, 'category_id')) { + $plain->category = $this->getCategoryName(); + unset($plain->category_id); + } + + return $plain; + } + + /** + * Export the child properties of this director property. + * + * @return array + */ + private function exportChildren(): array + { + $properties = []; + foreach ($this->fetchItemsFromDb() as $property) { + $properties[$property->get('key_name')] = $property->export(); + } + + return $properties; + } + + /** + * Get the child properties of this director property. + * + * @return DirectorProperty[] + */ + public function fetchItemsFromDb(): 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::quoteBinaryCompat($uuid->getBytes(), $this->db) + ); + + foreach (DirectorProperty::loadAll($this->connection, $query) as $item) { + foreach ($item->fetchItemsFromDb() as $nestedItem) { + $item->items[] = $nestedItem; + } + + $this->items[] = $item; + } + + return $this->items; + } + + public function getDatalist(): ?DirectorDatalist + { + if ($this->datalist) { + return $this->datalist; + } + + if (str_starts_with($this->get('value_type'), 'datalist-')) { + $query = $this->db->select()->from(['dd' => 'director_datalist'], ['list_name']) + ->join(['dpdl' => 'director_property_datalist'], 'dpdl.list_uuid = dd.uuid', []) + ->where($this->db->quoteInto( + 'dpdl.property_uuid = ?', + Db\DbUtil::quoteBinaryCompat($this->get('uuid'), $this->db) + )); + $this->datalist = DirectorDatalist::load($this->db->fetchOne($query), $this->connection); + } + + return $this->datalist; + } + + 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; + $datalist = null; + // DirectorProperty items (children) + $items = $plain->items ?? []; + unset($plain->items); + + // If DirectorProperty has a UUID, load it from the database using the "uuid" property + if ($uuid) { + $uuid = Uuid::fromString($uuid); + if (isset($plain->datalist)) { + $datalist = DirectorDatalist::loadOptional($plain->datalist, $db); + if (! $datalist && is_string($plain->datalist)) { + $datalist = DirectorDatalist::create(['list_name' => $plain->datalist], $db); + } + + unset($plain->datalist); + } + + $candidate = DirectorProperty::loadWithUniqueId($uuid, $db); + if ($candidate) { + assert($candidate instanceof DirectorProperty); + $candidate->setProperties((array) $plain); + $candidate->items = $candidate->importItems((array) $items, $db); + + return $candidate; + } + } + + // If DirectorProperty has no UUID (mainly for property children), + // load it from the database using the "key_name" property + $query = $dba->select()->from('director_property')->where('key_name = ?', $plain->key_name); + if (isset($plain->parent_uuid)) { + $query->where('parent_uuid = ?', Db\DbUtil::quoteBinaryCompat($plain->parent_uuid, $db->getDbAdapter())); + } else { + $query->where('parent_uuid is NULL'); + } + + $dbRow = $dba->fetchRow($query); + if ($dbRow !== false) { + $candidate = DirectorProperty::fromDbRow($dbRow, $db); + $export = $candidate->export(); + if (isset($export->parent_uuid)) { + $export->parent = DirectorProperty::loadWithUniqueId(Uuid::fromString($export->parent_uuid), $db) + ->get('key_name'); + unset($export->parent_uuid); + } + + CompareBasketObject::normalize($export); + $plainParentUuid = $plain->parent_uuid ?? null; + if (isset($plain->parent_uuid)) { + $parent = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($plain->parent_uuid), $db); + if ($parent === null) { + unset($plain->parent); + $plain->parent_uuid = $plainParentUuid; + } else { + $plain->parent = $parent->get('key_name'); + unset($plain->parent_uuid); + } + } + + unset($export->uuid); + if (CompareBasketObject::equals($export, $plain)) { + return $candidate; + } + + if ($plainParentUuid !== null) { + unset($plain->parent); + $plain->parent_uuid = $plainParentUuid; + } + } + + $property = static::create((array) $plain, $db); + + if ($datalist) { + $property->datalist = $datalist; + } + + if ($items) { + $property->items = $property->importItems((array) $items, $db); + } + + return $property; + } + + protected function onStore(): void + { + if ($this->getDatalist()) { + $this->db->insert( + 'director_property_datalist', + ['property_uuid' => $this->get('uuid'), 'list_uuid' => $this->datalist->get('uuid')] + ); + } + } + + /** + * Import the children of the director property recursively from the given array of imported + * items in the plain object. + * + * @param array $items + * @param Db $db + * + * @return array + */ + private function importItems(array $items, Db $db): array + { + if (empty($items)) { + return []; + } + + $itemCandidates = []; + foreach ($items as $key => $value) { + $itemUUid = $value->uuid ?? null; + $nestedItems = (array) ($value->items ?? []); + unset($value->items); + if ($itemUUid === null) { + continue; + } + + $itemUUid = Uuid::fromString($itemUUid); + $itemCandidate = DirectorProperty::loadWithUniqueId($itemUUid, $db); + if (! $itemCandidate) { + if (isset($value->parent_uuid)) { + $value->parent_uuid = Uuid::fromString($value->parent_uuid)->getBytes(); + } + + $itemCandidates[$key] = DirectorProperty::import($value, $db); + + continue; + } + + assert($itemCandidate instanceof DirectorProperty); + if (isset($value->parent_uuid)) { + $value->parent_uuid = Uuid::fromString($value->parent_uuid)->getBytes(); + } + + $datalist = null; + if (isset($value->datalist)) { + $datalist = DirectorDatalist::loadOptional($value->datalist, $db); + if (! $datalist && is_string($value->datalist)) { + $datalist = DirectorDatalist::create(['list_name' => $value->datalist], $db); + } + + unset($value->datalist); + } + + $itemCandidate->setProperties((array) $value); + + if ($datalist) { + $itemCandidate->datalist = $datalist; + } + + if ($nestedItems) { + $itemCandidate->items = $this->importItems($nestedItems, $db); + } + + $itemCandidates[$key] = $itemCandidate; + } + + return $itemCandidates; + } +} diff --git a/library/Director/Objects/IcingaCommand.php b/library/Director/Objects/IcingaCommand.php index b6ded87fd..fa8aceeb7 100644 --- a/library/Director/Objects/IcingaCommand.php +++ b/library/Director/Objects/IcingaCommand.php @@ -39,6 +39,8 @@ class IcingaCommand extends IcingaObject implements ObjectWithArguments, ExportI protected $supportsFields = true; + protected $supportsCustomProperties = true; + protected $supportsImports = true; protected $supportedInLegacy = true; diff --git a/library/Director/Objects/IcingaDependency.php b/library/Director/Objects/IcingaDependency.php index 440440e1d..f4ef586ef 100644 --- a/library/Director/Objects/IcingaDependency.php +++ b/library/Director/Objects/IcingaDependency.php @@ -32,6 +32,7 @@ class IcingaDependency extends IcingaObject implements ExportInterface 'redundancy_group' => null, 'assign_filter' => null, 'parent_service_by_name' => null, + 'redundancy_group' => null, ]; protected $uuidColumn = 'uuid'; diff --git a/library/Director/Objects/IcingaHost.php b/library/Director/Objects/IcingaHost.php index 7387eb275..0ece257b1 100644 --- a/library/Director/Objects/IcingaHost.php +++ b/library/Director/Objects/IcingaHost.php @@ -96,6 +96,8 @@ class IcingaHost extends IcingaObject implements ExportInterface protected $supportsFields = true; + protected $supportsCustomProperties = true; + protected $supportsChoices = true; protected $supportedInLegacy = true; 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/IcingaNotification.php b/library/Director/Objects/IcingaNotification.php index 476870455..fc2a0f011 100644 --- a/library/Director/Objects/IcingaNotification.php +++ b/library/Director/Objects/IcingaNotification.php @@ -39,6 +39,8 @@ class IcingaNotification extends IcingaObject implements ExportInterface protected $supportsFields = true; + protected $supportsCustomProperties = true; + protected $supportsImports = true; protected $supportsApplyRules = true; diff --git a/library/Director/Objects/IcingaObject.php b/library/Director/Objects/IcingaObject.php index 71b3539e1..21d89b0bb 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 { @@ -48,6 +49,9 @@ abstract class IcingaObject extends DbObject implements IcingaConfigRenderer /** @var bool Allows controlled custom var access through Fields */ protected $supportsFields = false; + /** @var bool Allows controlled custom var access through Custom Properties */ + protected $supportsCustomProperties = false; + /** @var bool Whether this object can be rendered as 'apply Object' */ protected $supportsApplyRules = false; @@ -377,6 +381,16 @@ public function supportsCustomVars() return $this->supportsCustomVars; } + /** + * Whether this Object supports custom properties + * + * @return bool + */ + public function supportsCustomProperties(): bool + { + return $this->supportsCustomProperties; + } + /** * Whether there exist Groups for this object type * @@ -1313,6 +1327,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(); @@ -1329,7 +1348,12 @@ protected function resolve($what) // $vals[$name]->$key = $value; $vals['_MERGED_']->$key = $value; - $vals['_INHERITED_']->$key = $value; + if (is_object($value)) { + $vals['_INHERITED_']->$key = clone $value; + } else { + $vals['_INHERITED_']->$key = $value; + } + $vals['_ORIGINS_']->$key = $origins->$key; } @@ -1341,9 +1365,35 @@ 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 +1402,21 @@ 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 +1511,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 +2318,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 7aa7a239c..6311e8775 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, @@ -98,6 +101,8 @@ class IcingaService extends IcingaObject implements ExportInterface protected $supportsFields = true; + protected $supportsCustomProperties = true; + protected $supportsImports = true; protected $supportsApplyRules = true; @@ -348,28 +353,47 @@ public function toConfigString() */ protected function renderObjectHeader() { + $applyFor = $this->get('apply_for'); if ( $this->isApplyRule() && !$this->hasBeenAssignedToHostTemplate() - && $this->get('apply_for') !== null + && $applyFor !== null ) { $name = $this->getObjectName(); $extraName = ''; + $applyForVar = substr($applyFor, strlen('host.vars.')); + if (preg_match('/[^a-zA-Z0-9_]/', $applyForVar)) { + $applyFor = 'host.vars["' . $applyForVar . '"]'; + } + + $isApplyFor = $this->isApplyRuleforDictionary($applyForVar); + $varName = '"' . $name . '"'; if (c::stringHasMacro($name)) { - $extraName = c::renderKeyValue('name', c::renderStringWithVariables($name)); + $macroWhiteList = $isApplyFor ? 'key' : 'value'; + $extraName = c::renderKeyValue('name', c::renderStringWithVariables($name, [$macroWhiteList])); $name = ''; } elseif ($name !== '') { $name = ' ' . c::renderString($name); } + if ($isApplyFor) { + $header = "%s %s%s for (key => value in %s) {\n"; + } else { + $header = "%s %s%s for (value in %s) {\n"; + } + + $extraInfo = sprintf("\n vars.overridenVar = %s\n", $varName); + return sprintf( - "%s %s%s for (config in %s) {\n", + $header, $this->getObjectTypeName(), $this->getType(), $name, - $this->get('apply_for') - ) . $extraName; + $applyFor + ) + . $extraName + . $extraInfo; } return parent::renderObjectHeader(); @@ -387,6 +411,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 +670,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.*', 'value[*]', 'value[*].*']; + 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/Objects/IcingaUser.php b/library/Director/Objects/IcingaUser.php index 41002451b..0d7cc19c0 100644 --- a/library/Director/Objects/IcingaUser.php +++ b/library/Director/Objects/IcingaUser.php @@ -32,6 +32,8 @@ class IcingaUser extends IcingaObject implements ExportInterface protected $supportsFields = true; + protected $supportsCustomProperties = true; + protected $supportsImports = true; protected $booleans = array( diff --git a/library/Director/Objects/IcingaUserGroup.php b/library/Director/Objects/IcingaUserGroup.php index 656235a22..753f0a539 100644 --- a/library/Director/Objects/IcingaUserGroup.php +++ b/library/Director/Objects/IcingaUserGroup.php @@ -16,6 +16,7 @@ class IcingaUserGroup extends IcingaObjectGroup 'disabled' => 'n', 'display_name' => null, 'zone_id' => null, + 'assign_filter' => null, ]; protected $relations = [ diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index 933fb33cc..df8dcce75 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -7,9 +7,11 @@ use Icinga\Exception\NotFoundError; use Icinga\Module\Director\CustomVariable\CustomVariableArray; use Icinga\Module\Director\Daemon\Logger; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; use Icinga\Module\Director\Db; use Icinga\Module\Director\Db\AppliedServiceSetLoader; use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Objects\IcingaService; use Icinga\Module\Director\Objects\IcingaTemplateResolver; use Icinga\Module\Director\Web\Form\IcingaObjectFieldLoader; @@ -24,6 +26,7 @@ use ipl\Html\Text; use ipl\Html\ValidHtml; use ipl\Orm\Model; +use PDO; use Throwable; class CustomVarRenderer extends CustomVarRendererHook @@ -31,12 +34,18 @@ class CustomVarRenderer extends CustomVarRendererHook /** @var array Related datafield configuration */ protected $fieldConfig = []; + /** @var array Related custom property configuration */ + protected $customPropertyConfig = []; + /** @var array Related datalists and their keys and values */ protected $datalistMaps = []; /** @var array Related dictionary field names */ protected $dictionaryNames = []; + /** @var array Related dictionary field names */ + protected $customPropertyDictionaries = []; + protected $dictionaryLevel = 0; /** @var HtmlElement Table for dictionary fields */ @@ -194,10 +203,6 @@ public function prefetchForObject(Model $object): bool $fields = (new IcingaObjectFieldLoader($directorServiceObj))->getFields(); } - if (empty($fields)) { - return false; - } - $fieldsWithDataLists = []; foreach ($fields as $field) { $this->fieldConfig[$field->get('varname')] = [ @@ -241,6 +246,85 @@ public function prefetchForObject(Model $object): bool } } + if ($service === null) { + $customProperties = $this->getObjectCustomProperties($directorHostObj); + } else { + $customProperties = $this->getObjectCustomProperties($directorServiceObj); + } + + if (empty($customProperties)) { + return true; + } + + $customPropertiesWithDatalists = []; + foreach ($customProperties as $customProperty) { + $propertyName = $customProperty['key_name']; + $this->customPropertyConfig[$propertyName] = ['label' => $customProperty['label']]; + if (isset($customProperty['category'])) { + $this->customPropertyConfig[$propertyName]['group'] = $customProperty['category']; + } + + if (str_starts_with($customProperty['value_type'], 'datalist-')) { + $customPropertiesWithDatalists[$customProperty['uuid']] = $customProperty; + } elseif (str_ends_with($customProperty['value_type'], '-dictionary')) { + $this->dictionaryNames[] = $customProperty['key_name']; + } + } + + $dictionaryItems = $db->select()->from( + ['dpp' => 'director_property'], + [] + ) + ->join(['dpc' => 'director_property'], 'dpp.uuid = dpc.parent_uuid', []) + ->columns([ + 'parent_name' => 'dpp.key_name', + 'key_name' => 'dpc.key_name', + 'label' => 'dpc.label', + 'value_type' => 'dpc.value_type', + 'uuid' => 'dpc.uuid' + ])->where('dpp.value_type', '*-dictionary'); + + foreach ($dictionaryItems as $dictionaryItem) { + $propertyName = $dictionaryItem->key_name; + + $this->customPropertyDictionaries[$dictionaryItem->parent_name][$propertyName] + = $dictionaryItem->label; + if (is_string($propertyName)) { + $this->customPropertyConfig[$propertyName] = ['label' => $dictionaryItem->label]; + } + + if (str_starts_with($dictionaryItem->value_type, 'datalist-')) { + $customPropertiesWithDatalists[$dictionaryItem->uuid] = $dictionaryItem; + } + } + + $dataListEntries = $db->select()->from( + ['dpd' => 'director_property_datalist'], + [ + 'property_uuid' => 'dpd.property_uuid', + 'entry_name' => 'dde.entry_name', + 'entry_value' => 'dde.entry_value', + 'property_name' => 'dpc.key_name', + ] + )->join( + ['dd' => 'director_datalist'], + 'dd.uuid = dpd.list_uuid', + [] + )->join( + ['dde' => 'director_datalist_entry'], + 'dd.id = dde.list_id', + [] + )->join( + ['dpc' => 'director_property'], + 'dpd.property_uuid = dpc.uuid', + [] + ); + + foreach ($dataListEntries as $dataListEntry) { + $this->datalistMaps[$dataListEntry->property_name][$dataListEntry->entry_name] + = $dataListEntry->entry_value; + } + return true; } catch (Throwable $e) { Logger::error("%s\n%s", $e, $e->getTraceAsString()); @@ -249,16 +333,79 @@ public function prefetchForObject(Model $object): bool } } + /** + * Get custom properties for the host. + * + * @return array + */ + protected function getObjectCustomProperties(IcingaObject $object, bool $isOverrideVars = false): array + { + if ($object->uuid === null) { + return []; + } + + $type = $object->getShortTableName(); + $parents = $object->listAncestorIds(); + + $uuids = []; + $db = $this->db(); + + $objectClass = DbObjectTypeRegistry::classByType($type); + foreach ($parents as $parent) { + $uuids[] = $objectClass::loadWithAutoIncId($parent, $db)->get('uuid'); + } + + $uuids[] = $object->get('uuid'); + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'category' => 'cpc.category_name', + $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', []) + ->joinLeft(['cpc' => 'director_datafield_category'], 'dp.category_id = cpc.id', []) + ->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', 'datalist-strict', 'datalist-non-strict'," + . " '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; + } + public function renderCustomVarKey(string $key) { try { - if (isset($this->fieldConfig[$key]['label'])) { - return new HtmlElement( - 'span', - Attributes::create(['title' => $this->fieldConfig[$key]['label'] . " [$key]"]), - Text::create($this->fieldConfig[$key]['label']) - ); + $label = $this->fieldConfig[$key]['label'] + ?? $this->customPropertyConfig[$key]['label'] + ?? null; + if ($label === null) { + return null; } + + return new HtmlElement( + 'span', + Attributes::create(['title' => $label . " [$key]"]), + Text::create($label) + ); } catch (Throwable $e) { Logger::error("%s\n%s", $e, $e->getTraceAsString()); } @@ -268,38 +415,40 @@ public function renderCustomVarKey(string $key) public function renderCustomVarValue(string $key, $value) { + if (! (isset($this->fieldConfig[$key]) || isset($this->customPropertyConfig[$key]))) { + return null; + } + try { - if (isset($this->fieldConfig[$key])) { - if ($this->fieldConfig[$key]['visibility'] === 'hidden') { - return '***'; - } + if (isset($this->fieldConfig[$key]) && $this->fieldConfig[$key]['visibility'] === 'hidden') { + return '***'; + } - if (is_array($value)) { - $renderedValue = []; - foreach ($value as $v) { - if (is_string($v) && isset($this->datalistMaps[$key][$v])) { - $renderedValue[] = new HtmlElement( - 'span', - Attributes::create(['title' => $this->datalistMaps[$key][$v] . " [$v]"]), - Text::create($this->datalistMaps[$key][$v]) - ); - } else { - $renderedValue[] = $v; - } + if (is_array($value) && ! isset($this->customPropertyDictionaries[$key])) { + $renderedValue = []; + foreach ($value as $k => $v) { + if (is_string($v) && isset($this->datalistMaps[$key][$v])) { + $renderedValue[$k] = new HtmlElement( + 'span', + Attributes::create(['title' => $this->datalistMaps[$key][$v] . " [$v]"]), + Text::create($this->datalistMaps[$key][$v]) + ); + } else { + $renderedValue[$k] = $v; } - - return $renderedValue; } - if (is_string($value) && isset($this->datalistMaps[$key][$value])) { - return new HtmlElement( - 'span', - Attributes::create(['title' => $this->datalistMaps[$key][$value] . " [$value]"]), - Text::create($this->datalistMaps[$key][$value]) - ); - } elseif ($value !== null && in_array($key, $this->dictionaryNames)) { - return $this->renderDictionaryVal($key, (array) $value); - } + return $renderedValue; + } + + if (is_string($value) && isset($this->datalistMaps[$key][$value])) { + return new HtmlElement( + 'span', + Attributes::create(['title' => $this->datalistMaps[$key][$value] . " [$value]"]), + Text::create($this->datalistMaps[$key][$value]) + ); + } elseif ($value !== null && in_array($key, $this->dictionaryNames)) { + return $this->renderDictionaryVal($key, (array) $value); } } catch (Throwable $e) { Logger::error("%s\n%s", $e, $e->getTraceAsString()); @@ -312,6 +461,8 @@ public function identifyCustomVarGroup(string $key): ?string { if (isset($this->fieldConfig[$key]['group'])) { return $this->fieldConfig[$key]['group']; + } elseif (isset($this->customPropertyConfig[$key]['group'])) { + return $this->customPropertyConfig[$key]['group']; } return null; @@ -354,8 +505,8 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml $this->dictionaryLevel++; - foreach ($value as $key => $val) { - if ($key !== null && is_array($val) || is_object($val)) { + foreach ($value as $k => $val) { + if ($k !== null && is_array($val) || is_object($val)) { $val = (array) $val; $numChildItems = count($val); @@ -363,7 +514,7 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml new HtmlElement( 'tr', Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), - new HtmlElement('th', null, Html::wantHtml($key)), + new HtmlElement('th', null, Html::wantHtml($k)), new HtmlElement( 'td', null, @@ -375,36 +526,39 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml $this->dictionaryLevel++; foreach ($val as $childKey => $childVal) { $childVal = $this->renderCustomVarValue($childKey, $childVal) ?? $childVal; - if (! in_array($childKey, $this->dictionaryNames)) { - $label = $this->renderCustomVarKey($childKey) ?? $childKey; - - if (is_array($childVal)) { - $this->renderArrayVal($label, $childVal); - } else { - $this->dictionaryBody->addHtml( - new HtmlElement( - 'tr', - Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), - new HtmlElement('th', null, Html::wantHtml( - $label - )), - new HtmlElement('td', null, Html::wantHtml($childVal)) - ) - ); - } + $label = $this->renderCustomVarKey($childKey) ?? $childKey; + if ( + ! in_array($childKey, $this->dictionaryNames) + && ! array_key_exists($childKey, $this->customPropertyDictionaries) + && is_array($childVal) + ) { + $this->renderArrayVal($label, $childVal); + } elseif (array_key_exists($childKey, $this->customPropertyDictionaries)) { + $this->renderArrayVal($label, $childVal, $childKey); + } else { + $this->dictionaryBody->addHtml( + new HtmlElement( + 'tr', + Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), + new HtmlElement('th', null, Html::wantHtml( + $label + )), + new HtmlElement('td', null, Html::wantHtml($childVal)) + ) + ); } } $this->dictionaryLevel--; } elseif (is_array($val)) { - $this->renderArrayVal($key, $val); + $this->renderArrayVal($key, $val, $key); } else { $this->dictionaryBody->addHtml( new HtmlElement( 'tr', Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), - new HtmlElement('th', null, Html::wantHtml($key)), - new HtmlElement('td', null, Html::wantHtml($val)) + new HtmlElement('th', null, $this->renderCustomVarKey($key) ?? Html::wantHtml($key)), + new HtmlElement('td', null, $this->renderCustomVarValue($key, $val) ?? Html::wantHtml($val)) ) ); } @@ -420,23 +574,29 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml } /** - * Render an array + * Render an array, if the passed key is a part of dictionary, then render as a dictionary * * @param HtmlElement|string $name * @param array $array * * @return void */ - protected function renderArrayVal($name, array $array) + protected function renderArrayVal($name, array $array, ?string $key = null): void { $numItems = count($array); + if ($key) { + $prefix = ' (Dictionary)'; + } else { + $prefix = ' (Array)'; + } + if ($name instanceof HtmlElement) { - $name->addHtml(Text::create(' (Array)')); + $name->addHtml(Text::create($prefix)); } else { $name = (new HtmlDocument())->addHtml( Html::wantHtml($name), - Text::create(' (Array)') + Text::create($prefix) ); } @@ -453,12 +613,24 @@ protected function renderArrayVal($name, array $array) ksort($array); foreach ($array as $key => $value) { + if (! $key instanceof HtmlElement) { + $renderedKey = $this->renderCustomVarKey($key) ?? Html::wantHtml("[$key]"); + } else { + $renderedKey = Html::wantHtml("[$key]"); + } + + if (! $value instanceof HtmlElement) { + $renderedValue = $this->renderCustomVarValue($key, $value) ?? Html::wantHtml($value); + } else { + $renderedValue = Html::wantHtml($value); + } + $this->dictionaryBody->addHtml( new HtmlElement( 'tr', Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), - new HtmlElement('th', null, Html::wantHtml("[$key]")), - new HtmlElement('td', null, Html::wantHtml($value)) + new HtmlElement('th', null, $renderedKey), + new HtmlElement('td', null, $renderedValue) ) ); } diff --git a/library/Director/Resolver/TemplateTree.php b/library/Director/Resolver/TemplateTree.php index 5d5d19eb7..8a5645eb1 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; @@ -388,6 +390,8 @@ protected function prepareTree() $rootNodes = []; $children = []; $names = []; + $parentsUuids = []; + $uuids = []; foreach ($templates as $row) { $id = (int) $row->id; $pid = (int) $row->parent_id; @@ -406,6 +410,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] = []; @@ -414,7 +419,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; @@ -460,6 +466,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 369fb49b5..41acd8052 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -7,13 +7,17 @@ use Icinga\Exception\NotFoundError; use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\CustomVariable\CustomVariables; 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 +95,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 getObjectCustomProperties(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,63 +167,171 @@ 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); - } else { - $data = array_merge([ - 'object_type' => $object->get('object_type'), - 'object_name' => $object->getObjectName() - ], $data); - $object->replaceWith(IcingaObject::createByType($type, $data, $db)); + $object = $this->loadOptionalObject(); + $actionName = $this->request->getActionName(); + + $overRiddenCustomVars = []; + if ($actionName === 'variables') { + $overRiddenCustomVars = $data; + } else { + // Extract custom vars from the data + if (isset($data['vars'])) { + $overRiddenCustomVars = (array) $data['vars']; + + unset($data['vars']); } - // Avoid cyclic imports for hosts and commands - if (in_array($object->getShortTableName(), ['host', 'command'], true)) { - if (in_array((int) $object->get('id'), $object->listAncestorIds())) { - throw new RuntimeException( - 'Import loop detected for the object ' - . $object->getObjectName() . ' -> Imports: ' - . implode(', ', $object->getImports()) - ); + foreach ($data as $key => $value) { + if (substr($key, 0, 5) === 'vars.') { + $overRiddenCustomVars[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)); + } + + // Avoid cyclic imports for hosts and commands + if (in_array($object->getShortTableName(), ['host', 'command'], true)) { + if (in_array((int) $object->get('id'), $object->listAncestorIds())) { + throw new RuntimeException( + 'Import loop detected for the object ' + . $object->getObjectName() . ' -> Imports: ' + . implode(', ', $object->getImports()) + ); + } + + if (isset($data['imports']) && in_array($object->get('object_name'), $data['imports'])) { + throw new RuntimeException( + 'You can not import the same object into itself: ' . $object->getObjectName() + ); + } } - if (isset($data['imports']) && in_array($object->get('object_name'), $data['imports'])) { - throw new RuntimeException( - 'You can not import the same object into itself: ' . $object->getObjectName() - ); + $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 { + $object = IcingaObject::createByType($type, $data, $db); + $this->persistChanges($object); } + } - $this->persistChanges($object); + if (empty($overRiddenCustomVars)) { $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'); + + break; + } + + $objectVars = $object->vars(); + if ($request->getMethod() === 'PUT') { + $objectWhere = $db->getDbAdapter()->quoteInto("{$type}_id = ?", $this->object->get('id')); + $db->getDbAdapter()->delete( + 'icinga_' . $type . '_var', + $objectWhere + ); + + $objectPropertyWhere = $db->getDbAdapter()->quoteInto( + "{$type}_uuid = ?", + Uuid::fromBytes(DbUtil::binaryResult($this->object->get('uuid')))->getBytes() + ); + $db->getDbAdapter()->delete( + 'icinga_' . $type . '_property', + $objectPropertyWhere + ); + + $objectVars = new CustomVariables(); + } + + $customProperties = $this->getObjectCustomProperties($object); + + foreach ($overRiddenCustomVars as $key => $value) { + $objectVars->set($key, $value); + $objectVars->get($key)->setModified(); + if (isset($customProperties[$key])) { + $objectVars->registerVarUuid($key, Uuid::fromBytes($customProperties[$key]['uuid'])); + + continue; } - $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)); + + if ($actionName !== 'variables') { + continue; + } + + if (! $object->isTemplate()) { + throw new NotFoundError(sprintf( + 'The custom property %s should be first added to one of the imported templates' + . ' for this object', + $key + )); + } + + if ($request->getMethod() === 'POST') { + $errMsg = sprintf( + 'The custom property %s should be first added to the template', + $key + ); + + throw new NotFoundError($errMsg); + } + + $query = $db->getDbAdapter() + ->select() + ->from(['dp' => 'director_property'], ['uuid']) + ->where('dp.key_name = ? AND dp.parent_uuid IS NULL', $key); + $customPropertyUuid = $db->getDbAdapter()->fetchOne($query); + + if (! $customPropertyUuid) { + throw new NotFoundError(sprintf( + "'%s' is not configured in Icinga Director as a custom property", + $key + )); + } + + $db->getDbAdapter()->insert( + 'icinga_' . $type . '_property', + [ + 'property_uuid' => $customPropertyUuid, + $type . '_uuid' => DbUtil::quoteBinaryCompat($object->get('uuid'), $db->getDbAdapter()) + ] + ); + + $objectVars->registerVarUuid($key, Uuid::fromBytes($customPropertyUuid)); } - break; + $objectVars->storeToDb($object); + $object = IcingaObject::loadByType($type, $object->getObjectName(), $db); + $this->sendJson($object->toPlainObject(false, true)); + + break; case 'GET': $object = $this->requireObject(); $exporter = new Exporter($this->db); RestApiParams::applyParamsToExporter($exporter, $this->request, $object->getShortTableName()); $this->sendJson($exporter->export($object)); - break; + break; default: $request->getResponse()->setHttpResponseCode(400); throw new IcingaException('Unsupported method ' . $request->getMethod()); diff --git a/library/Director/Web/Controller/ActionController.php b/library/Director/Web/Controller/ActionController.php index 568df3507..826e06f02 100644 --- a/library/Director/Web/Controller/ActionController.php +++ b/library/Director/Web/Controller/ActionController.php @@ -18,6 +18,7 @@ use Icinga\Module\Director\Web\Window; use Icinga\Security\SecurityException; use Icinga\Web\Controller; +use Icinga\Web\Session; use Icinga\Web\UrlParams; use InvalidArgumentException; use gipfl\IcingaWeb2\Translator; diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index efa9f5972..d476805c3 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -8,19 +8,24 @@ use Icinga\Exception\NotFoundError; use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Dashboard\Dashlet\DeploymentDashlet; +use Icinga\Module\Director\Data\Db\DbConnection; 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\Db\DbUtil; use Icinga\Module\Director\Deployment\DeploymentInfo; use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Forms\CustomVariablesForm; use Icinga\Module\Director\Forms\DeploymentLinkForm; +use Icinga\Module\Director\Forms\DictionaryElements\Dictionary; use Icinga\Module\Director\Forms\IcingaCloneObjectForm; use Icinga\Module\Director\Forms\IcingaObjectFieldForm; use Icinga\Module\Director\Objects\DirectorDeploymentLog; +use Icinga\Module\Director\Forms\ObjectCustomvarForm; use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Objects\IcingaObjectGroup; use Icinga\Module\Director\Objects\IcingaService; @@ -36,7 +41,15 @@ use Icinga\Module\Director\Web\Tabs\ObjectTabs; use Icinga\Module\Director\Web\Widget\BranchedObjectHint; use gipfl\IcingaWeb2\Link; +use Icinga\Web\Notification; +use Icinga\Web\Session; +use ipl\Html\Attributes; use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Web\Url; +use ipl\Web\Widget\ButtonLink; +use PDO; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; @@ -62,6 +75,9 @@ abstract class ObjectController extends ActionController /** @var string|null */ protected $objectBaseUrl; + /** @var Session\SessionNamespace */ + protected Session\SessionNamespace $session; + public function init() { $this->enableStaticObjectLoader($this->getTableName()); @@ -72,6 +88,13 @@ public function init() if ($this->getRequest()->isApiRequest()) { $this->initializeRestApi(); } else { + $this->session = Session::getSession()->getNamespace('director.variables'); + if (! $this->params->shift('_preserve_session')) { + $this->session->delete('vars'); + $this->session->delete('added-properties'); + $this->session->delete('removed-properties'); + } + $this->initializeWebRequest(); } } @@ -103,6 +126,17 @@ protected function initializeRestApi() protected function initializeWebRequest() { + $action = $this->getRequest()->getActionName(); + if (! ($action === 'variables' || $action === 'add-var')) { + $this->session->delete('vars'); + $this->session->delete('added-properties'); + $this->session->delete('removed-properties'); + } + + if ($this->getRequest()->getActionName() === 'add-var') { + return; + } + if ($this->getRequest()->getActionName() === 'add') { $this->addSingleTab( sprintf($this->translate('Add %s'), ucfirst($this->getType())), @@ -270,6 +304,40 @@ public function fieldsAction() } } + public function addVarAction() + { + $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 ObjectCustomvarForm($this->db(), $object)) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(ObjectCustomvarForm::ON_SUBMIT, function (ObjectCustomvarForm $form) use ($objectUuid) { + $properties = $this->session->get('added-properties', []); + $removedObjectProperties = $this->session->get('removed-properties', []); + $propertyName = $form->getPropertyName(); + if (array_key_exists($propertyName, $removedObjectProperties)) { + unset($removedObjectProperties[$propertyName]); + } elseif (! isset($properties[$propertyName])) { + $properties[$propertyName] = Uuid::fromString($form->getValue('property'))->getBytes(); + } + + $this->session->set('added-properties', $properties); + $this->session->set('removed-properties', $removedObjectProperties); + $this->redirectNow(Url::fromPath( + 'director/' . $this->getType() . '/variables', + [ + 'uuid' => UUid::fromBytes($objectUuid)->toString(), + '_preserve_session' => true + ] + )); + }) + ->handleRequest($this->getServerRequest()); + + $this->content()->add($form); + } + protected function addFieldsFormAndTable($object, $type) { $form = IcingaObjectFieldForm::load() @@ -296,6 +364,471 @@ protected function addFieldsFormAndTable($object, $type) $table->renderTo($this); } + public function variablesAction(): void + { + $this->assertPermission('director/admin'); + $object = $this->requireObject(); + + $this->addTitle( + $this->translate('Custom Variables: %s'), + $object->getObjectName() + ); + + $this->prepareApplyForHeader(); + if ($this->object->isTemplate()) { + $this->actions()->add( + (new ButtonLink( + $this->translate('Add Custom Variable'), + Url::fromPath( + 'director/' . $this->getType() . '/add-var', + ['uuid' => $this->getUuidFromUrl(), '_preserve_session' => true] + )->getAbsoluteUrl(), + null, + ['class' => 'control-button'] + ))->openInModal() + ); + } + + $form = $this->prepareCustomPropertiesForm($object); + if ($form) { + $this->content()->add($form->handleRequest($this->getServerRequest())); + } + + $this->tabs()->activate('variables'); + } + + /** + * Prepare Custom Properties Form for hosts, services, apply rules and service sets + * + * @param IcingaObject $object + * @param IcingaHost|null $host + * + * @return ?CustomVariablesForm + */ + public function prepareCustomPropertiesForm( + IcingaObject $object, + ?IcingaHost $host = null + ): ?CustomVariablesForm { + $addedProperties = $this->session->get('added-properties'); + $removedProperties = $this->session->get('removed-properties'); + + $isOverrideVars = $host !== null; + if (! $isOverrideVars) { + $storedVars = $object->getVars(); + unset($storedVars->{'_override_servicevars'}); + } else { + $storedVars = $host->getOverriddenServiceVars($object); + } + + if ($this->session->get('vars')) { + $vars = $this->session->get('vars'); + } else { + $vars = json_decode(json_encode($storedVars), true); + + $this->session->set('vars', $vars); + } + + $inheritedVars = json_decode(json_encode($object->getInheritedVars()), JSON_OBJECT_AS_ARRAY); + $origins = $object->getOriginsVars(); + + $objectProperties = $this->getObjectCustomProperties($object, $isOverrideVars); + if (empty($objectProperties) && empty($addedProperties) && empty($removedProperties)) { + $this->content()->add(Hint::info($this->translate('No custom properties defined.'))); + + return null; + } + + $result = []; + foreach ($objectProperties as $row) { + if (array_key_exists($row['key_name'], $vars)) { + $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 = (new CustomVariablesForm($object, $objectProperties)) + ->setAction(Url::fromRequest()->setParam('_preserve_session')->getAbsoluteUrl()); + + $form->on( + CustomVariablesForm::ON_SUBMIT, + function (CustomVariablesForm $form) { + $this->session->delete('vars'); + $this->session->delete('added-properties'); + $this->session->delete('removed-properties'); + if ($form->varsHasBeenModified()) { + Notification::success( + sprintf( + $this->translate('Custom variables have been successfully modified for %s'), + $form->object->getObjectName(), + ) + ); + } else { + Notification::success($this->translate('There is nothing to change.')); + } + + $this->redirectNow(Url::fromRequest()->without(['_preserve_session'])); + } + )->on( + CustomVariablesForm::ON_SENT, + function (CustomVariablesForm $form) { + /** @var Dictionary $propertiesElement */ + $propertiesElement = $form->getElement('properties'); + $vars = $propertiesElement->getDictionary(); + $this->session->set('vars', $vars); + } + ); + + $form->load($result); + + return $form; + } + + + private function prepareApplyForHeader(): void + { + if (! ($this->object instanceof IcingaService) || $this->object->get('apply_for') === null) { + return; + } + + $applyFor = $this->object->get('apply_for'); + $fetchVar = $this->fetchVar(substr($applyFor, strlen('host.vars.'))); + if (empty($fetchVar)) { + return; + } + + $this->content()->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => ['apply-for-header']]), + HtmlElement::create( + 'div', + Attributes::create(['class' => ['apply-for-header-content']]), + [ + Text::create(sprintf( + $this->translate( + 'The values of selected host variable for apply-for-rule' + . ' is accessible through %s.' + ), + '$value$' + )) + ] + ) + )); + + if ($fetchVar->value_type !== 'dynamic-dictionary') { + return; + } + + $dictionaryKeys = $this->fetchNestedDictionaryKeys($fetchVar->uuid); + if (empty($dictionaryKeys)) { + return; + } + + $content = []; + $configVariables = new HtmlElement('table', Attributes::create(['class' => 'key-value-table'])); + foreach ($dictionaryKeys as $keyAttributes) { + if (str_contains($keyAttributes['key_name'], ' ')) { + continue; + } + + if (preg_match('/[^a-zA-Z0-9_]/', $keyAttributes['key_name'])) { + $config = '$value["' . $keyAttributes['key_name'] . '"]'; + } else { + $config = '$value.' . $keyAttributes['key_name']; + } + + $content = [$this->createKey( + $keyAttributes['key_name'], + $keyAttributes['label'] ?? $keyAttributes['key_name'] + )]; + + if ($keyAttributes['value_type'] !== 'fixed-dictionary') { + $content[] = $this->createValue($config . '$'); + + $configVariables->addHtml(new HtmlElement( + 'tr', + Attributes::create(['class' => 'key-value-item']), + ...$content + )); + + continue; + } + + $nestedContent = []; + foreach ($this->fetchNestedDictionaryKeys($keyAttributes['uuid']) as $nestedKeyAttributes) { + if (str_contains($nestedKeyAttributes['key_name'], ' ')) { + continue; + } + + if (preg_match('/[^a-zA-Z0-9_]/', $nestedKeyAttributes['key_name'])) { + $nestedConfig = $config . '["' . $nestedKeyAttributes['key_name'] . '"]$'; + } else { + $nestedConfig = $config . '.' . $nestedKeyAttributes['key_name'] . '$'; + } + + $nestedContent[] = new HtmlElement('div', null, Text::create($nestedConfig)); + $nestedKeyName = $nestedKeyAttributes['key_name']; + $nestedLabel = $nestedKeyAttributes['label'] ?? $nestedKeyAttributes['key_name']; + $nestedContent = [ + $this->createKey($nestedKeyName, $nestedLabel), + $this->createValue($nestedConfig) + ]; + } + + if (preg_match('/[^a-zA-Z0-9_]/', $keyAttributes['key_name'])) { + $value = '$value["' . $keyAttributes['key_name'] . '"]$'; + } else { + $value = '$value.' . $keyAttributes['key_name'] . '$'; + } + + $content[] = new HtmlElement( + 'td', + Attributes::create(['class' => 'value']), + new HtmlElement( + 'div', + null, + new HtmlElement( + 'div', + null, + Text::create($value) + ), + new HtmlElement( + 'table', + Attributes::create(['class' => 'key-value-table']), + new HtmlElement( + 'tr', + Attributes::create( + ['class' => 'key-value-item'] + ), + ...$nestedContent + ) + ) + ) + ); + + $configVariables->addHtml( + new HtmlElement( + 'tr', + Attributes::create(['class' => 'key-value-item']), + ...$content + ) + ); + } + + if (empty($content)) { + return; + } + + $this->content()->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => ['apply-for-header']]), + HtmlElement::create( + 'div', + Attributes::create(['class' => ['apply-for-header-content']]), + [ + Text::create($this->translate( + 'Nested keys of selected host dictionary variable for apply-for-rule' + . ' are accessible through value as shown in the table below:' + )), + $configVariables + ] + ) + )); + } + + private function fetchNestedDictionaryKeys(string $dictionaryUuid) + { + $db = $this->db(); + $query = $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 = ?", DbUtil::quoteBinaryCompat($dictionaryUuid, $db->getDbAdapter())); + + return $db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + } + + protected function fetchVar(string $varName) + { + $db = $this->object->getConnection(); + $query = $db->select() + ->from( + ['dp' => 'director_property'], + ['*'] + ) + ->where('parent_uuid IS NULL AND key_name ', $varName); + + return $db->getDbAdapter()->fetchRow($query); + } + + private function valueTypeOrderExpr(DbConnection $db, array $types): string + { + if ($db->isPgsql()) { + $cases = []; + foreach ($types as $i => $type) { + $cases[] = "WHEN '$type' THEN " . ($i + 1); + } + return 'CASE dp.value_type ' . implode(' ', $cases) . ' ELSE ' . (count($types) + 1) . ' END'; + } + + return "FIELD(dp.value_type, '" . implode("', '", $types) . "')"; + } + + /** + * Get custom properties for the host. + * + * @return array + */ + protected function getObjectCustomProperties(IcingaObject $object, bool $isOverrideVars = false): array + { + if ($object->uuid === null) { + return []; + } + + $type = $object->getShortTableName(); + $parents = $object->listAncestorIds(); + + $uuids = []; + $db = $this->db(); + foreach ($parents as $parent) { + $uuids[] = DbUtil::quoteBinaryCompat( + IcingaObject::loadByType($type, $parent, $db)->get('uuid'), + $db->getDbAdapter() + ); + } + + $objectUuid = $object->get('uuid'); + $uuids[] = Dbutil::quoteBinaryCompat($objectUuid, $db->getDbAdapter()); + $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', $type . '_uuid']) + ->order($this->valueTypeOrderExpr($db, [ + 'string', + 'number', + 'bool', + 'datalist-strict', + 'datalist-non-strict', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary' + ])) + ->order('children') + ->order('key_name'); + + $result = []; + $removedProperties = $this->session->get('removed-properties', []); + if ($isOverrideVars) { + if ($object->isApplyRule()) { + $serviceName = $object->getObjectName(); + } else { + $serviceName = $this->params->getRequired('service'); + } + + $vars = json_decode(json_encode($this->object->getOverriddenServiceVars($serviceName)), true); + } else { + $vars = json_decode(json_encode($object->getVars()), true); + } + + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + $row['uuid'] = DbUtil::binaryResult($row['uuid']); + $row[$type . '_uuid'] = DbUtil::binaryResult($row[$type . '_uuid']); + if ($objectUuid === $row[$type . '_uuid']) { + $row['allow_removal'] = true; + } else { + $row['allow_removal'] = false; + } + + if (isset($vars[$row['key_name']])) { + $row['value'] = $vars[$row['key_name']]; + } + + if (array_key_exists($row['key_name'], $removedProperties)) { + $row['removed'] = true; + } + + $result[$row['key_name']] = $row; + } + + $addedProperties = $this->session->get('added-properties'); + if ($addedProperties) { + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label', + 'children' => 'COUNT(cdp.uuid)' + ] + ) + ->joinLeft( + ['cdp' => 'director_property'], + 'cdp.parent_uuid = dp.uuid', + [] + ) + ->where('dp.' . 'uuid IN (?)', DbUtil::quoteBinaryCompat($addedProperties, $db->getDbAdapter())) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->order($this->valueTypeOrderExpr($db, [ + 'string', 'number', 'bool', 'datalist-strict', 'datalist-non-strict', + 'dynamic-array', 'fixed-array', 'fixed-dictionary', 'dynamic-dictionary' + ])) + ->order('children') + ->order('key_name'); + + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + $row['allow_removal'] = true; + $row['host_uuid'] = DbUtil::binaryResult($this->object->get('uuid')); + $row['uuid'] = DbUtil::binaryResult($row['uuid']); + if (! isset($result[$row['key_name']])) { + $row['new'] = true; + } + + if (isset($vars[$row['key_name']])) { + $row['value'] = $vars[$row['key_name']]; + } + + $result[$row['key_name']] = $row; + } + } + + return $result; + } + /** * @throws NotFoundError * @throws \Icinga\Security\SecurityException @@ -767,4 +1300,30 @@ protected function showOptionalBranchActivity($activityTable) } } } + + private function createKey(mixed $keyName, mixed $label): HtmlElement + { + return new HtmlElement( + 'td', + Attributes::create(['class' => 'key']), + new HtmlElement( + 'div', + null, + Text::create($label . ' (' . $keyName . ')') + ) + ); + } + + private function createValue(string $value) + { + return new HtmlElement( + 'td', + Attributes::create(['class' => 'value']), + new HtmlElement( + 'div', + null, + Text::create($value) + ) + ); + } } diff --git a/library/Director/Web/Form/DirectorObjectForm.php b/library/Director/Web/Form/DirectorObjectForm.php index d83741d5c..e0b337a79 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; @@ -823,11 +824,13 @@ protected function onRequest() if ($this->object !== null) { $this->setDefaultsFromObject($this->object); } + $this->prepareFields($this->object()); IcingaObjectFormHook::callOnSetup($this); if ($this->hasBeenSent()) { $this->handlePost(); } + try { $this->loadInheritedProperties(); $this->addFields(); diff --git a/library/Director/Web/Form/Element/ArrayElement.php b/library/Director/Web/Form/Element/ArrayElement.php new file mode 100644 index 000000000..8fc4ff42c --- /dev/null +++ b/library/Director/Web/Form/Element/ArrayElement.php @@ -0,0 +1,98 @@ + 'array-input']; + + /** @var array Predefined values used for validation and term labels ['value' => 'label'] */ + private array $suggestedValues = []; + + 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() + ->set('data-no-auto-submit-on-remove', false) + ->registerAttributeCallback('placeholder', function () use ($valuePlaceHolder) { + return $valuePlaceHolder; + }) + ->registerAttributeCallback('data-auto-submit', function () { + return $this->shouldAutoSubmit; + }); + } + + public function shouldAutoSubmit(bool $shouldAutoSubmit = true): self + { + $this->shouldAutoSubmit = $shouldAutoSubmit; + + return $this; + } + + 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) { + $term = new RegisteredTerm($term); + if (isset($this->suggestedValues[$term->getSearchValue()])) { + $term->setLabel($this->suggestedValues[$term->getSearchValue()]); + } + + $terms[] = $term; + } + + return $this->setTerms(...$terms); + } + + public function setSuggestedValues(array $suggestedValues): self + { + $this->suggestedValues = $suggestedValues; + + return $this; + } +} diff --git a/library/Director/Web/Form/Element/IplBoolean.php b/library/Director/Web/Form/Element/IplBoolean.php new file mode 100644 index 000000000..10c8c2bc3 --- /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 => '', + }; + + return parent::isSelectedOption( + $optionValue + ); + } +} diff --git a/library/Director/Web/Form/IcingaObjectFieldLoader.php b/library/Director/Web/Form/IcingaObjectFieldLoader.php index 2e5a69a23..e0770f230 100644 --- a/library/Director/Web/Form/IcingaObjectFieldLoader.php +++ b/library/Director/Web/Form/IcingaObjectFieldLoader.php @@ -60,16 +60,6 @@ public function addFieldsToForm(DirectorObjectForm $form) return $this; } - /** - * Get element names to variable names map (Example: ['elName' => 'varName']) - * - * @return array - */ - public function getNameMap(): array - { - return $this->nameMap; - } - public function loadFieldsForMultipleObjects($objects) { $fields = array(); diff --git a/library/Director/Web/Table/IcingaHostAppliedServicesTable.php b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php index 415903b40..a372be598 100644 --- a/library/Director/Web/Table/IcingaHostAppliedServicesTable.php +++ b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php @@ -30,6 +30,8 @@ class IcingaHostAppliedServicesTable extends SimpleQueryBasedTable private $allApplyRules; + private $useDeprecatedLink = false; + /** * @param IcingaHost $host * @return static @@ -103,12 +105,18 @@ public function renderRow($row) $applyFor = sprintf('(apply for %s) ', $row->apply_for); } + $url = 'director/host/appliedservice'; + + if ($this->useDeprecatedLink) { + $url .= 'deprecated'; + } + $link = Link::create(sprintf( $this->translate('%s %s(%s)'), $row->name, $applyFor, $this->renderApplyFilter($row->filter) - ), 'director/host/appliedservice', [ + ), $url, [ 'name' => $this->host->getObjectName(), 'service_id' => $row->id, ]); @@ -117,6 +125,13 @@ public function renderRow($row) return $this::row([$link], $attributes); } + public function useDeprecatedLink(bool $useDeprecatedLink = true): self + { + $this->useDeprecatedLink = $useDeprecatedLink; + + return $this; + } + /** * @param Filter $assignFilter * diff --git a/library/Director/Web/Table/IcingaServiceSetServiceTable.php b/library/Director/Web/Table/IcingaServiceSetServiceTable.php index 1ea8a28be..6b704f73b 100644 --- a/library/Director/Web/Table/IcingaServiceSetServiceTable.php +++ b/library/Director/Web/Table/IcingaServiceSetServiceTable.php @@ -39,6 +39,8 @@ class IcingaServiceSetServiceTable extends ZfQueryBasedTable /** @var string|null */ protected $highlightedService; + private $useDeprecatedLink = false; + /** * @param IcingaServiceSet $set * @return static @@ -101,6 +103,13 @@ public function highlightService($service) return $this; } + public function useDeprecatedLink(bool $useDeprecatedLink = true): self + { + $this->useDeprecatedLink = $useDeprecatedLink; + + return $this; + } + /** * @param $row * @return BaseHtmlElement @@ -122,6 +131,9 @@ protected function getServiceLink($row) 'set' => $row->service_set ]; $url = 'director/host/servicesetservice'; + if ($this->useDeprecatedLink) { + $url .= 'deprecated'; + } } else { if (is_resource($row->uuid)) { $row->uuid = stream_get_contents($row->uuid); diff --git a/library/Director/Web/Table/ObjectsTableService.php b/library/Director/Web/Table/ObjectsTableService.php index d7d7462f4..60a268375 100644 --- a/library/Director/Web/Table/ObjectsTableService.php +++ b/library/Director/Web/Table/ObjectsTableService.php @@ -20,6 +20,8 @@ class ObjectsTableService extends ObjectsTable protected $title; + private $useDeprecatedLink = false; + /** @var IcingaHost */ protected $inheritedBy; @@ -61,6 +63,13 @@ public function setTitle($title) return $this; } + public function useDeprecatedLink(bool $useDeprecatedLink = true): self + { + $this->useDeprecatedLink = $useDeprecatedLink; + + return $this; + } + public function setHost(IcingaHost $host) { $this->host = $host; @@ -150,9 +159,14 @@ protected function getInheritedServiceLink($row, $target) 'inheritedFrom' => $row->host, ]; + $url = 'director/host/inheritedservice'; + if ($this->useDeprecatedLink) { + $url .= 'deprecated'; + } + return Link::create( $row->object_name, - 'director/host/inheritedservice', + $url, $params ); } diff --git a/library/Director/Web/Tabs/ObjectTabs.php b/library/Director/Web/Tabs/ObjectTabs.php index 77a4654cc..74606d07c 100644 --- a/library/Director/Web/Tabs/ObjectTabs.php +++ b/library/Director/Web/Tabs/ObjectTabs.php @@ -7,6 +7,7 @@ use Icinga\Module\Director\Objects\IcingaObject; use ipl\I18n\Translation; use gipfl\IcingaWeb2\Widget\Tabs; +use Icinga\Module\Director\Objects\IcingaServiceSet; class ObjectTabs extends Tabs { @@ -98,7 +99,15 @@ protected function addTabsForExistingObject() $this->add('fields', array( 'url' => sprintf('director/%s/fields', $type), 'urlParams' => $params, - 'label' => $this->translate('Fields') + 'label' => $this->translate('Fields (Deprecated)') + )); + } + + if ($auth->hasPermission(Permission::ADMIN) && $this->hasCustomProperties()) { + $this->add('variables', array( + 'url' => sprintf('director/%s/variables', $type), + 'urlParams' => $params, + 'label' => $this->translate('Custom Variables') )); } @@ -157,4 +166,14 @@ protected function hasFields() && $object->supportsFields() && ($object->isTemplate() || $this->type === 'command'); } + + protected function hasCustomProperties() + { + if (! ($object = $this->object)) { + return false; + } + + return $object->hasBeenLoadedFromDb() + && $object->supportsCustomProperties(); + } } diff --git a/library/Director/Web/Widget/CustomVarFieldsTable.php b/library/Director/Web/Widget/CustomVarFieldsTable.php new file mode 100644 index 000000000..1f6e10211 --- /dev/null +++ b/library/Director/Web/Widget/CustomVarFieldsTable.php @@ -0,0 +1,58 @@ + 'common-table table-row-selectable custom-var-fields-table', + 'data-base-target' => '_next', + ]; + + public function __construct( + protected array $properties, + protected bool $isFieldsTable = false + ) { + } + + protected function assemble() + { + foreach ($this->properties as $property) { + $propertyUuid = DbUtil::binaryResult($property->uuid); + $url = Url::fromPath( + 'director/customvar', + ['uuid' => Uuid::fromBytes($propertyUuid)->toString()] + ); + + if (isset($property->parent_uuid)) { + $parentUuid = DbUtil::binaryResult($property->parent_uuid); + $url->addParams(['parent_uuid' => Uuid::fromBytes($parentUuid)->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/library/Director/Web/Widget/CustomVarObjectList.php b/library/Director/Web/Widget/CustomVarObjectList.php new file mode 100644 index 000000000..6f3f865a6 --- /dev/null +++ b/library/Director/Web/Widget/CustomVarObjectList.php @@ -0,0 +1,69 @@ +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 + { + $item = parent::createListItem($data); + if ($this->getDetailActionsDisabled()) { + return $item; + } + + $objectInstance = $data->object_class; + if ($data->object_class === 'service' && $data->host_name !== null) { + $filter = Filter::all( + Filter::equal('name', $data->name), + Filter::equal('host_name', $data->host_name) + ); + } else { + $filter = Filter::equal('name', $data->name); + } + + $url = Url::fromPath("director/$objectInstance/variables"); + $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..485dab546 --- /dev/null +++ b/library/Director/Web/Widget/CustomVarRenderer.php @@ -0,0 +1,65 @@ +addHtml(Html::sprintf( + '%s', + $this->createSubject($item, $layout), + )); + } + + protected function createSubject($item, string $layout): Link + { + $objectClass = $item->object_class; + if ($objectClass === 'service' && $item->host_name !== null) { + $params = ['name' => $item->name, 'host_name' => $item->host_name]; + } else { + $params = ['name' => $item->name]; + } + + return new Link( + $item->name, + Url::fromPath("director/$objectClass/variables", $params)->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/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-var-fields-table.less b/public/css/custom-var-fields-table.less new file mode 100644 index 000000000..5292b441f --- /dev/null +++ b/public/css/custom-var-fields-table.less @@ -0,0 +1,3 @@ +.common-table.custom-var-fields-table { + max-width: 100%; +} \ No newline at end of file diff --git a/public/css/custom-variable-form.less b/public/css/custom-variable-form.less new file mode 100644 index 000000000..92cdae5bb --- /dev/null +++ b/public/css/custom-variable-form.less @@ -0,0 +1,5 @@ +.custom-variable-form { + .btn-remove { + .button(@body-bg-color, @color-critical, @color-critical-accentuated); + } +} \ No newline at end of file diff --git a/public/css/custom-variables-form.less b/public/css/custom-variables-form.less new file mode 100644 index 000000000..1e21a34e4 --- /dev/null +++ b/public/css/custom-variables-form.less @@ -0,0 +1,162 @@ +// Style +.custom-variables-form { + .btn-primary:disabled { + background-color: @gray-light; + } + + .btn-discard:disabled { + background: @gray-light; + color: @disabled-gray; + border-color: transparent; + } + + .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; + } + } + } + + .dictionary-item.removable { + border: 1px solid @gray-light; + border-radius: 1em; + .remove-property { + width: fit-content; + border: none; + color: @color-critical; + &:hover { + background-color: @color-critical; + color: @text-color-inverted; + } + } + } + } +} + +.nested-dictionary-item.collapsed > legend::before { + content: '\e820'; +} + +.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'; + } +} + +// Layout +.custom-variables-form { + .buttons { + display: flex; + justify-content: space-between; + } + + // TODO: Use this in JS to position the footer at the bottom of the page + .sticky-footer { + position: fixed; + bottom: 0; + // Width properties taken from icingaweb2/public/css/icinga/forms.less + width: 80%; + max-width: 70em; + } + + .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 { + .btn-primary.add-item { + width: 100%; + justify-content: center; + } + } + + .dictionary-item.removable { + .remove-property { + position: absolute; + top: 0; + right: 0; + margin-top: -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/host-service-deactivate-form.less b/public/css/host-service-deactivate-form.less new file mode 100644 index 000000000..7b7a8be3d --- /dev/null +++ b/public/css/host-service-deactivate-form.less @@ -0,0 +1,12 @@ +// Layout +.host-service-deactivate-form { + &.active { + float: right; + font-size: 0.9em; + } + + &.deactivated { + max-width: 60em; // width taken from the hint element in gipfl library + text-align: center; + } +} \ 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..dfebfa4b0 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -17,6 +17,47 @@ table.common-table td { } } +.key-value-table { + vertical-align: baseline; + padding: 0; + margin: 0.5em 0; + width: 100%; + + .key-value-item { + .key { + width: 25%; + border-right: 2px solid @gray-light; + } + + .value { + width: 75%; + } + + div { + margin-right: 1em; + } + } +} + +.key-value-table, +.key-value-item { + border: 2px solid @gray-light; +} + +.custom-var-usage-header { + margin-left: 0.5em; +} + +.apply-for-header { + width: 80%; + border-radius: 0.5em; + border: 1px solid @gray-light; + background: @gray-lightest; + .apply-for-header-content { + padding: 0.5em; + } +} + #layout.minimal-layout table.common-table td { padding-top: 0.5em; padding-bottom: 0.5em; @@ -490,6 +531,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 @@ -945,20 +990,19 @@ form.director-form { width: auto; } - p.description { + p.description:not(.deprecated-data-field) { color: @gray; font-style: italic; padding: 0.25em 0.5em; display: none; } - dd.active { - p.description { - font-style: normal; - display: block; - height: auto; - color: @text-color; - } + dd.active p.description, + dd p.description.deprecated-data-field { + font-style: normal; + display: block; + height: auto; + color: @text-color; } } 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_192.sql b/schema/mysql-migrations/upgrade_192.sql new file mode 100644 index 000000000..d6159d757 --- /dev/null +++ b/schema/mysql-migrations/upgrade_192.sql @@ -0,0 +1,169 @@ +CREATE TABLE director_property ( + uuid binary(16) NOT NULL, + parent_uuid binary(16) DEFAULT NULL, + key_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + label varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + value_type enum( + 'string', + 'number', + 'bool', + 'fixed-array', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary', + 'datalist-strict', + 'datalist-non-strict' + ) COLLATE utf8mb4_unicode_ci NOT NULL, + category_id INT(10) UNSIGNED DEFAULT NULL, + description text, + parent_uuid_v BINARY(16) AS (COALESCE(parent_uuid, 0x00000000000000000000000000000000)) STORED, + PRIMARY KEY (uuid), + UNIQUE INDEX unique_name_parent_uuid (key_name, parent_uuid_v), + CONSTRAINT director_property_category + FOREIGN KEY category (category_id) + REFERENCES director_datafield_category (id) + ON DELETE RESTRICT + ON UPDATE CASCADE +) 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; + +CREATE TABLE icinga_service_property ( + service_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (service_uuid, property_uuid), + CONSTRAINT icinga_service_property_service + FOREIGN KEY service(service_uuid) + REFERENCES icinga_service (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_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; + +CREATE TABLE icinga_command_property ( + command_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (command_uuid, property_uuid), + CONSTRAINT icinga_command_property_command + FOREIGN KEY command(command_uuid) + REFERENCES icinga_command (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_command_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; + +CREATE TABLE icinga_notification_property ( + notification_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (notification_uuid, property_uuid), + CONSTRAINT icinga_notification_property_notification + FOREIGN KEY notification(notification_uuid) + REFERENCES icinga_notification (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_notification_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; + +CREATE TABLE icinga_service_set_property ( + service_set_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (service_set_uuid, property_uuid), + CONSTRAINT icinga_service_set_property_service_set + FOREIGN KEY service_set(service_set_uuid) + REFERENCES icinga_service_set (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_set_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; + +CREATE TABLE icinga_user_property ( + user_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (user_uuid, property_uuid), + CONSTRAINT icinga_user_property_user + FOREIGN KEY user (user_uuid) + REFERENCES icinga_user (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_user_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 director_datalist + ADD UNIQUE KEY (uuid); + +CREATE TABLE director_property_datalist ( + list_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + CONSTRAINT director_list_property_list + FOREIGN KEY list (list_uuid) + REFERENCES director_datalist (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT director_property_list_property + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin; + +ALTER TABLE icinga_host_var + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; + +ALTER TABLE icinga_service_var + ADD COLUMN property_uuid binary(16); + +ALTER TABLE icinga_command_var + ADD COLUMN property_uuid binary(16); + +ALTER TABLE icinga_notification_var + ADD COLUMN property_uuid binary(16); + +ALTER TABLE icinga_service_set_var + ADD COLUMN property_uuid binary(16); + +ALTER TABLE icinga_user_var + ADD COLUMN property_uuid binary(16); + +INSERT INTO director_schema_migration +(schema_version, migration_time) +VALUES (192, NOW()); diff --git a/schema/mysql.sql b/schema/mysql.sql index 0830fc647..99021f0d9 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -461,6 +461,7 @@ CREATE TABLE icinga_command_var ( varvalue TEXT DEFAULT NULL, format ENUM('string', 'expression', 'json') NOT NULL DEFAULT 'string', checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(16) DEFAULT NULL, PRIMARY KEY (command_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), @@ -651,20 +652,69 @@ CREATE TABLE icinga_host_field ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE director_property ( + uuid varbinary(16) NOT NULL, + parent_uuid varbinary(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', + 'datalist-strict', + 'datalist-non-strict' + ) COLLATE utf8mb4_unicode_ci NOT NULL, + category_id INT(10) UNSIGNED DEFAULT NULL, + parent_uuid_v BINARY(16) AS (COALESCE(parent_uuid, 0x00000000000000000000000000000000)) STORED, + PRIMARY KEY (uuid), + UNIQUE INDEX unique_name_parent_uuid (key_name, parent_uuid_v), + CONSTRAINT director_property_category + FOREIGN KEY category (category_id) + REFERENCES director_datafield_category (id) + ON DELETE RESTRICT + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE icinga_host_property ( + host_uuid varbinary(16) NOT NULL, + property_uuid varbinary(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 MEDIUMTEXT DEFAULT NULL, format enum ('string', 'json', 'expression'), -- immer string vorerst checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(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 @@ -810,6 +860,7 @@ CREATE TABLE icinga_service_var ( varvalue TEXT DEFAULT NULL, format enum ('string', 'json', 'expression'), checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(16) DEFAULT NULL, PRIMARY KEY (service_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), @@ -901,6 +952,7 @@ CREATE TABLE icinga_service_set_var ( varvalue TEXT DEFAULT NULL, format ENUM('string', 'expression', 'json') NOT NULL DEFAULT 'string', checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(16) DEFAULT NULL, PRIMARY KEY (service_set_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), @@ -1150,6 +1202,7 @@ CREATE TABLE icinga_user_var ( varvalue TEXT DEFAULT NULL, format ENUM('string', 'json', 'expression') NOT NULL DEFAULT 'string', checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(16) DEFAULT NULL, PRIMARY KEY (user_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), @@ -1300,6 +1353,7 @@ CREATE TABLE icinga_notification_var ( varvalue TEXT DEFAULT NULL, format enum ('string', 'json', 'expression'), checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(16) DEFAULT NULL, PRIMARY KEY (notification_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), @@ -2446,6 +2500,109 @@ CREATE TABLE branched_icinga_dependency ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE icinga_service_property ( + service_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (service_uuid, property_uuid), + CONSTRAINT icinga_service_property_service + FOREIGN KEY service(service_uuid) + REFERENCES icinga_service (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_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; + +CREATE TABLE icinga_command_property ( + command_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (command_uuid, property_uuid), + CONSTRAINT icinga_command_property_command + FOREIGN KEY command(command_uuid) + REFERENCES icinga_command (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_command_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; + +CREATE TABLE icinga_notification_property ( + notification_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (notification_uuid, property_uuid), + CONSTRAINT icinga_notification_property_notification + FOREIGN KEY notification(notification_uuid) + REFERENCES icinga_notification (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_notification_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; + +CREATE TABLE icinga_service_set_property ( + service_set_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (service_set_uuid, property_uuid), + CONSTRAINT icinga_service_set_property_service_set + FOREIGN KEY service_set(service_set_uuid) + REFERENCES icinga_service_set (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_set_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; + +CREATE TABLE icinga_user_property ( + user_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (user_uuid, property_uuid), + CONSTRAINT icinga_user_property_user + FOREIGN KEY user (user_uuid) + REFERENCES icinga_user (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_user_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 director_datalist + ADD UNIQUE KEY (uuid); + +CREATE TABLE director_property_datalist ( + list_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + CONSTRAINT director_list_property_list + FOREIGN KEY list (list_uuid) + REFERENCES director_datalist (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT director_property_list_property + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin; + INSERT INTO director_schema_migration (schema_version, migration_time) - VALUES (191, NOW()); + VALUES (192, NOW()); diff --git a/schema/pgsql-migrations/upgrade_192.sql b/schema/pgsql-migrations/upgrade_192.sql new file mode 100644 index 000000000..1122bed3d --- /dev/null +++ b/schema/pgsql-migrations/upgrade_192.sql @@ -0,0 +1,180 @@ +CREATE TYPE enum_property_value_type AS ENUM( + 'string', + 'number', + 'bool', + 'fixed-array', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary', + 'datalist-strict', + 'datalist-non-strict' +); + +CREATE TABLE director_property ( + uuid bytea CHECK(LENGTH(uuid) = 16) NOT NULL, + parent_uuid bytea CHECK(LENGTH(parent_uuid) = 16) DEFAULT NULL, + key_name character varying(255) NOT NULL, + label character varying(255) DEFAULT NULL, + description text DEFAULT NULL, + value_type enum_property_value_type NOT NULL, + category_id integer DEFAULT NULL, + PRIMARY KEY (uuid), + CONSTRAINT director_property_category + FOREIGN KEY (category_id) + REFERENCES director_datafield_category (id) + ON DELETE RESTRICT + ON UPDATE CASCADE +); + +-- Unique key_name at root level (no parent) +CREATE UNIQUE INDEX unique_property_name_root + ON director_property (key_name) + WHERE parent_uuid IS NULL; + +-- Unique (key_name, parent_uuid) for nested properties +CREATE UNIQUE INDEX unique_property_name_parent + ON director_property (key_name, parent_uuid) + WHERE parent_uuid IS NOT NULL; + +CREATE TABLE icinga_host_property ( + host_uuid bytea CHECK(LENGTH(host_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (host_uuid, property_uuid), + CONSTRAINT icinga_host_property_host + FOREIGN KEY (host_uuid) + REFERENCES icinga_host (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE icinga_service_property ( + service_uuid bytea CHECK(LENGTH(service_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (service_uuid, property_uuid), + CONSTRAINT icinga_service_property_service + FOREIGN KEY (service_uuid) + REFERENCES icinga_service (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE icinga_command_property ( + command_uuid bytea CHECK(LENGTH(command_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (command_uuid, property_uuid), + CONSTRAINT icinga_command_property_command + FOREIGN KEY (command_uuid) + REFERENCES icinga_command (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_command_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE icinga_notification_property ( + notification_uuid bytea CHECK(LENGTH(notification_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (notification_uuid, property_uuid), + CONSTRAINT icinga_notification_property_notification + FOREIGN KEY (notification_uuid) + REFERENCES icinga_notification (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_notification_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE icinga_service_set_property ( + service_set_uuid bytea CHECK(LENGTH(service_set_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (service_set_uuid, property_uuid), + CONSTRAINT icinga_service_set_property_service_set + FOREIGN KEY (service_set_uuid) + REFERENCES icinga_service_set (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_set_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE icinga_user_property ( + user_uuid bytea CHECK(LENGTH(user_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (user_uuid, property_uuid), + CONSTRAINT icinga_user_property_user + FOREIGN KEY (user_uuid) + REFERENCES icinga_user (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_user_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS director_datalist_uuid_unique + ON director_datalist (uuid); + +CREATE TABLE director_property_datalist ( + list_uuid bytea CHECK(LENGTH(list_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + CONSTRAINT director_list_property_list + FOREIGN KEY (list_uuid) + REFERENCES director_datalist (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT director_property_list_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +ALTER TABLE icinga_host_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_service_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_command_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_notification_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_service_set_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_user_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +INSERT INTO director_schema_migration + (schema_version, migration_time) + VALUES (192, NOW()); diff --git a/schema/pgsql.sql b/schema/pgsql.sql index 6647699bf..648947980 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -17,6 +17,18 @@ CREATE TYPE enum_merge_behaviour AS ENUM('set', 'add', 'substract', 'override'); CREATE TYPE enum_set_merge_behaviour AS ENUM('override', 'extend', 'blacklist'); CREATE TYPE enum_command_object_type AS ENUM('object', 'template', 'external_object'); CREATE TYPE enum_apply_object_type AS ENUM('object', 'template', 'apply', 'external_object'); +CREATE TYPE enum_property_value_type AS ENUM( + 'string', + 'number', + 'bool', + 'fixed-array', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary', + 'datalist-strict', + 'datalist-non-strict' +); + CREATE TYPE enum_state_name AS ENUM('OK', 'Warning', 'Critical', 'Unknown', 'Up', 'Down'); CREATE TYPE enum_type_name AS ENUM('DowntimeStart', 'DowntimeEnd', 'DowntimeRemoved', 'Custom', 'Acknowledgement', 'Problem', 'Recovery', 'FlappingStart', 'FlappingEnd'); CREATE TYPE enum_sync_rule_object_type AS ENUM( @@ -246,7 +258,7 @@ CREATE INDEX start_time_idx ON director_deployment_log (start_time); CREATE TABLE director_datalist ( id serial, - uuid bytea CHECK(LENGTH(uuid) = 16) NOT NULL, + uuid bytea UNIQUE CHECK(LENGTH(uuid) = 16) NOT NULL, list_name character varying(255) NOT NULL, owner character varying(255) NOT NULL, PRIMARY KEY (id) @@ -319,6 +331,47 @@ CREATE TABLE director_datafield_setting ( CREATE INDEX director_datafield_datafield ON director_datafield_setting (datafield_id); +CREATE TABLE director_property ( + uuid bytea CHECK(LENGTH(uuid) = 16) NOT NULL, + parent_uuid bytea CHECK(LENGTH(parent_uuid) = 16) DEFAULT NULL, + key_name character varying(255) NOT NULL, + label character varying(255) DEFAULT NULL, + description text DEFAULT NULL, + value_type enum_property_value_type NOT NULL, + category_id integer DEFAULT NULL, + PRIMARY KEY (uuid), + CONSTRAINT director_property_category + FOREIGN KEY (category_id) + REFERENCES director_datafield_category (id) + ON DELETE RESTRICT + ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX unique_property_name_root + ON director_property (key_name) + WHERE parent_uuid IS NULL; + +CREATE UNIQUE INDEX unique_property_name_parent + ON director_property (key_name, parent_uuid) + WHERE parent_uuid IS NOT NULL; + +CREATE TABLE director_property_datalist ( + list_uuid bytea CHECK(LENGTH(list_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + CONSTRAINT director_list_property_list + FOREIGN KEY (list_uuid) + REFERENCES director_datalist (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT director_property_list_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + CREATE TABLE director_schema_migration ( schema_version SMALLINT NOT NULL, migration_time TIMESTAMP WITH TIME ZONE NOT NULL, @@ -573,12 +626,31 @@ CREATE TABLE icinga_command_field ( ); +CREATE TABLE icinga_command_property ( + command_uuid bytea CHECK(LENGTH(command_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (command_uuid, property_uuid), + CONSTRAINT icinga_command_property_command + FOREIGN KEY (command_uuid) + REFERENCES icinga_command (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_command_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + CREATE TABLE icinga_command_var ( command_id integer NOT NULL, checksum bytea DEFAULT NULL UNIQUE CHECK(LENGTH(checksum) = 20), varname character varying(255) NOT NULL, varvalue text DEFAULT NULL, format enum_property_format NOT NULL DEFAULT 'string', + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (command_id, varname), CONSTRAINT icinga_command_var_command FOREIGN KEY (command_id) @@ -802,12 +874,30 @@ CREATE INDEX host_field_datafield ON icinga_host_field (datafield_id); COMMENT ON COLUMN icinga_host_field.host_id IS 'Makes only sense for templates'; +CREATE TABLE icinga_host_property ( + host_uuid bytea CHECK(LENGTH(host_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (host_uuid, property_uuid), + CONSTRAINT icinga_host_property_host + FOREIGN KEY (host_uuid) + REFERENCES icinga_host (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + CREATE TABLE icinga_host_var ( host_id integer NOT NULL, checksum bytea DEFAULT NULL UNIQUE CHECK(LENGTH(checksum) = 20), varname character varying(255) NOT NULL, varvalue text DEFAULT NULL, format enum_property_format, -- immer string vorerst + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (host_id, varname), CONSTRAINT icinga_host_var_host FOREIGN KEY (host_id) @@ -975,12 +1065,31 @@ CREATE INDEX service_inheritance_service ON icinga_service_inheritance (service_ CREATE INDEX service_inheritance_service_parent ON icinga_service_inheritance (parent_service_id); +CREATE TABLE icinga_service_property ( + service_uuid bytea CHECK(LENGTH(service_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (service_uuid, property_uuid), + CONSTRAINT icinga_service_property_service + FOREIGN KEY (service_uuid) + REFERENCES icinga_service (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + CREATE TABLE icinga_service_var ( service_id integer NOT NULL, checksum bytea DEFAULT NULL UNIQUE CHECK(LENGTH(checksum) = 20), varname character varying(255) NOT NULL, varvalue text DEFAULT NULL, format enum_property_format, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (service_id, varname), CONSTRAINT icinga_service_var_service FOREIGN KEY (service_id) @@ -1088,12 +1197,31 @@ CREATE INDEX service_set_inheritance_set ON icinga_service_set_inheritance (serv CREATE INDEX service_set_inheritance_parent ON icinga_service_set_inheritance (parent_service_set_id); +CREATE TABLE icinga_service_set_property ( + service_set_uuid bytea CHECK(LENGTH(service_set_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (service_set_uuid, property_uuid), + CONSTRAINT icinga_service_set_property_service_set + FOREIGN KEY (service_set_uuid) + REFERENCES icinga_service_set (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_set_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + CREATE TABLE icinga_service_set_var ( service_set_id integer NOT NULL, checksum bytea DEFAULT NULL UNIQUE CHECK(LENGTH(checksum) = 20), varname character varying(255) NOT NULL, varvalue text DEFAULT NULL, format enum_property_format NOT NULL DEFAULT 'string', + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (service_set_id, varname), CONSTRAINT icinga_service_set_var_service_set FOREIGN KEY (service_set_id) @@ -1370,6 +1498,7 @@ CREATE TABLE icinga_user_var ( varname character varying(255) NOT NULL, varvalue text DEFAULT NULL, format enum_property_format NOT NULL DEFAULT 'string', + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (user_id, varname), CONSTRAINT icinga_user_var_user FOREIGN KEY (user_id) @@ -1407,6 +1536,24 @@ CREATE INDEX user_field_datafield ON icinga_user_field (datafield_id); COMMENT ON COLUMN icinga_user_field.user_id IS 'Makes only sense for templates'; +CREATE TABLE icinga_user_property ( + user_uuid bytea CHECK(LENGTH(user_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (user_uuid, property_uuid), + CONSTRAINT icinga_user_property_user + FOREIGN KEY (user_uuid) + REFERENCES icinga_user (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_user_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + CREATE TABLE icinga_usergroup ( id serial, uuid bytea UNIQUE CHECK(LENGTH(uuid) = 16), @@ -1822,6 +1969,7 @@ CREATE TABLE icinga_notification_var ( varname VARCHAR(255) NOT NULL, varvalue TEXT DEFAULT NULL, format enum_property_format, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (notification_id, varname), CONSTRAINT icinga_notification_var_notification FOREIGN KEY (notification_id) @@ -1858,6 +2006,24 @@ CREATE INDEX notification_field_datafield ON icinga_notification_field (datafiel COMMENT ON COLUMN icinga_notification_field.notification_id IS 'Makes only sense for templates'; +CREATE TABLE icinga_notification_property ( + notification_uuid bytea CHECK(LENGTH(notification_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (notification_uuid, property_uuid), + CONSTRAINT icinga_notification_property_notification + FOREIGN KEY (notification_uuid) + REFERENCES icinga_notification (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_notification_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + CREATE TABLE icinga_notification_inheritance ( notification_id integer NOT NULL, parent_notification_id integer NOT NULL, @@ -2783,4 +2949,4 @@ CREATE INDEX branched_dependency_search_object_name ON branched_icinga_dependenc INSERT INTO director_schema_migration (schema_version, migration_time) - VALUES (190, NOW()); + VALUES (192, NOW()); diff --git a/test/php/library/Director/CustomVariable/CustomVariablesTest.php b/test/php/library/Director/CustomVariable/CustomVariablesTest.php index c5ba9f67f..424aedda6 100644 --- a/test/php/library/Director/CustomVariable/CustomVariablesTest.php +++ b/test/php/library/Director/CustomVariable/CustomVariablesTest.php @@ -48,7 +48,7 @@ public function testNumericKeysAreRenderedWithArraySyntax() $this->assertEquals( $expected, - $vars->toConfigString(true) + $vars->toConfigString(null, true) ); } @@ -61,7 +61,7 @@ public function testVariablesToExpression() 'vars.abc = "$val$"', 'vars.bla = "da"' ]); - $this->assertEquals($expected, $vars->toConfigString(true)); + $this->assertEquals($expected, $vars->toConfigString(null, true)); } protected function indentVarsList($vars) 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"