diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index efd40c7f6..95bbb122d 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -104,6 +104,13 @@ jobs: git clone --depth 1 -b snapshot/nightly https://github.com/Icinga/icinga-php-library.git _libraries/ipl git clone --depth 1 -b snapshot/nightly https://github.com/Icinga/icinga-php-thirdparty.git _libraries/vendor + - name: Setup Icingadb + run: | + git clone --depth 1 https://github.com/Icinga/icingadb-web.git _icingaweb2/modules/icingadb + mkdir -p test/config/enabledModules + cd _icingaweb2/modules/icingadb + ln -s `pwd` ../../../test/config/enabledModules/icingadb + - name: Setup Incubator run: | git clone --depth 1 https://github.com/Icinga/icingaweb2-module-incubator _icingaweb2/modules/incubator 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..a4fb9ee22 100644 --- a/application/clicommands/HostsCommand.php +++ b/application/clicommands/HostsCommand.php @@ -3,6 +3,11 @@ namespace Icinga\Module\Director\Clicommands; use Icinga\Module\Director\Cli\ObjectsCommand; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use PDO; +use Ramsey\Uuid\Uuid; /** * Manage Icinga Hosts @@ -11,4 +16,106 @@ */ class HostsCommand extends ObjectsCommand { + /** + * Updates the custom variables associated with objects by syncing them with their corresponding + * custom variable uuids, and stores the updated variables in the database. + * + * @return void + */ + public function refreshCustomVarsAction(): void + { + foreach ($this->getObjects() as $o) { + $vars = $o->vars(); + $objectCustomVariables = $this->getObjectCustomVariables($o); + + foreach ($objectCustomVariables 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); + } + } + + /** + * Retrieves custom variables of the given Icinga object. + * + * @param IcingaObject $object The Icinga object whose custom variables need to be fetched. + * + * @return array An associative array of custom variables keyed by their names and their configuration details. + */ + private function getObjectCustomVariables(IcingaObject $object): array + { + if ($object->uuid === null) { + return []; + } + + $objectType = $object->getShortTableName(); + + $parents = $object->listAncestorIds(); + + $uuids = []; + $db = $object->getConnection(); + + foreach ($parents as $parent) { + $uuids[] = DbUtil::binaryResult(IcingaHost::loadWithAutoIncId($parent, $db)->get('uuid')); + } + + $uuids[] = DbUtil::binaryResult($object->get('uuid')); + $types = [ + 'string', + 'sensitive', + 'number', + 'bool', + 'fixed-array', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary' + ]; + + if ($db->isPgsql()) { + $cases = []; + foreach ($types as $i => $type) { + $cases[] = "WHEN '$type' THEN " . ($i + 1); + } + + $valueTypeOrder = 'CASE dp.value_type ' . implode(' ', $cases) . ' ELSE ' . (count($types) + 1) . ' END'; + } else { + $valueTypeOrder = "FIELD(dp.value_type, '" . implode("', '", $types) . "')"; + } + + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + $objectType . '_uuid' => 'iop.' . $objectType . '_uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label', + 'children' => 'COUNT(cdp.uuid)' + ] + ) + ->join(['iop' => "icinga_$objectType" . '_property'], 'dp.uuid = iop.property_uuid', []) + ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) + ->where( + 'iop.' . $objectType . '_uuid IN (?)', + DbUtil::quoteBinaryCompat($uuids, $db->getDbAdapter()) + ) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->order($valueTypeOrder) + ->order('children') + ->order('key_name'); + + $result = []; + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + $result[$row['key_name']] = DbUtil::normalizeRow($row); + } + + return $result; + } } diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php new file mode 100644 index 000000000..3fec1f993 --- /dev/null +++ b/application/clicommands/MigrateCommand.php @@ -0,0 +1,604 @@ + 'datafield_name'] */ + private $migratedDataFields = []; + + /** + * Show what would be migrated, without making any changes + * + * USAGE + * + * icingacli director migrate summary + */ + public function summaryAction() + { + $customPropertiesToMigrate = $this->prepareCustomProperties(); + $this->printMigrationDetails($customPropertiesToMigrate); + + $totalMigrated = 0; + foreach ($customPropertiesToMigrate as $customProperty) { + if (! str_starts_with($customProperty['value_type'], 'unsupported-')) { + $totalMigrated++; + } + } + + $totalSkipped = count(DirectorDatafield::loadAll($this->db())) - $totalMigrated; + + echo "Summary:\n"; + printf("Total number of datafields that will be migrated: %d\n", $totalMigrated); + printf("Total number of datafields that will be skipped: %d\n", $totalSkipped); + } + + /** + * Run datafield migration + * + * USAGE + * + * icingacli director migrate datafields --dry-run --delete --verbose + * + * OPTIONS + * + * --dry-run Preview what would be migrated without writing to the database + * + * --delete Remove original datafield records and their bindings after migration (skipped with --dry-run) + * + * --verbose Show detailed migration results + */ + public function datafieldsAction() + { + $db = $this->db(); + $customPropertiesToMigrate = $this->prepareCustomProperties(); + $dryRun = $this->params->shift('dry-run') ?? false; + $delete = $this->params->shift('delete') ?? false; + // Dry run summary + if ($dryRun) { + $this->printMigrationDetails($customPropertiesToMigrate); + } + + echo "Migrating Data fields\n"; + + foreach ($this->existingCustomProperties as $varname) { + unset($customPropertiesToMigrate[$varname]); + + if ($this->isVerbose) { + echo "[-] Skipping migrating datafield '$varname' as a custom variable" + . " 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->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, $delete && ! $dryRun); + } + + echo "Migration completed\n"; + + $totalMigrated = 0; + foreach ($customPropertiesToMigrate as $customProperty) { + if (! str_starts_with($customProperty['value_type'], 'unsupported-')) { + $totalMigrated++; + } + } + + $totalSkipped = count(DirectorDatafield::loadAll($db)) - $totalMigrated; + if ($delete) { + echo "The following datafields have been migrated and deleted:\n"; + if ($this->isVerbose) { + foreach ($this->migratedDataFields as $dataField) { + echo "$dataField \n"; + } + } + } + + echo "Summary:\n"; + printf("Total number of datafields migrated: %d\n", $totalMigrated); + printf("Total number of datafields skipped: %d\n", $totalSkipped); + } + + /** + * Print the datatype/category/duplicate breakdown, and reconcile it against + * datafields that are skipped because a custom property with the same name + * already exists, so the final migrated/skipped totals aren't a mystery. + * + * @param array $customPropertiesToMigrate + * + * @return void + */ + private function printMigrationDetails(array $customPropertiesToMigrate): void + { + $this->checkMigrateableDatafieldTypes(); + $this->checkDatafieldsWithCategory(); + $this->checkUnmigrateableDatafieldTypes(); + $this->checkDatafieldsWithDuplicateNames(); + + $supportedDatatypeCount = count($customPropertiesToMigrate) + count($this->existingCustomProperties); + printf( + "Of the %d datafields with a supported datatype, %d already have a matching new custom variable" + . " and will be skipped\n\n", + $supportedDatatypeCount, + count($this->existingCustomProperties) + ); + } + + /** + * 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') { + $customProperty['value_type'] = $dataType === 'boolean' ? 'bool' : $dataType; + } elseif ($dataType === 'string') { + $settings = DirectorDatafield::load($row->id, $db)->getSettings(); + $customProperty['value_type'] = ($settings['visibility'] ?? null) === 'hidden' + ? 'sensitive' + : 'string'; + } elseif ($dataType === 'datalist') { + $datalist = DirectorDatafield::load($row->id, $db); + $settings = $datalist->getSettings(); + $behaviour = $settings['behavior'] ?? 'strict'; + 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'; + + if (isset($settings['datalist_id'])) { + $customProperty['datalist_uuid'] = DirectorDatalist::loadWithAutoIncId( + $settings['datalist_id'], + $db + )->get('uuid'); + } + } else { + $customProperty['value_type'] = "unsupported-$dataType"; + } + + $customProperties[$row->varname] = $customProperty; + } + + return $customProperties; + } + + /** + * Migrate given prepared custom properties + * + * When $delete is set, the legacy datafield bindings and definitions are removed + * as part of the very same transaction as the migration, so a failure on either + * side rolls back both instead of leaving migrated properties next to a partially + * deleted legacy datafield. + * + * @param array $customProperties + * + * @return void + */ + private function migrateDatafields(array $customProperties, bool $dryRun, bool $delete = false): void + { + $db = $this->db(); + + $migrate = function () use ($db, $customProperties, $dryRun) { + $dbAdapter = $db->getDbAdapter(); + foreach ($customProperties as $varName => $customProperty) { + if (str_starts_with($customProperty['value_type'], 'unsupported-')) { + if ($this->isVerbose) { + 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']); + } + + $datalistUuidBytes = null; + if (isset($customProperty['datalist_uuid'])) { + $datalistUuidBytes = $customProperty['datalist_uuid']; + unset($customProperty['datalist_uuid']); + } + + if (! $dryRun) { + $datafieldId = $customProperty['datafield_id']; + unset($customProperty['datafield_id']); + $this->migratedDataFields[$datafieldId] = $varName; + $uuidBytes = $customProperty['uuid']; + $customProperty['uuid'] = DbUtil::quoteBinaryCompat($uuidBytes, $dbAdapter); + $db->insert('director_property', $customProperty); + $propertyUuid = Uuid::fromBytes($uuidBytes); + + if ($itemType !== null) { + $childUuidBytes = Uuid::uuid4()->getBytes(); + $db->insert('director_property', [ + 'uuid' => DbUtil::quoteBinaryCompat($childUuidBytes, $dbAdapter), + 'key_name' => 0, + 'value_type' => $itemType, + 'parent_uuid' => DbUtil::quoteBinaryCompat($uuidBytes, $dbAdapter) + ]); + } + + if ($datalistUuidBytes !== null) { + $db->insert('director_property_datalist', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($uuidBytes, $dbAdapter), + 'list_uuid' => DbUtil::quoteBinaryCompat($datalistUuidBytes, $dbAdapter) + ]); + } + + $this->migrateDatafieldObjectTemplateBinding($datafieldId, $propertyUuid, $varName); + } + + if ($dryRun) { + echo "[*] Would migrate datafield '$varName'\n"; + } elseif ($this->isVerbose) { + echo "[+] Datafield '$varName' successfully migrated\n"; + } + } + }; + + if ($dryRun) { + $migrate(); + + return; + } + + if ($delete) { + $db->runFailSafeTransaction(function () use ($migrate) { + $migrate(); + $this->deleteMigratedDataFields(); + }); + } else { + $db->runFailSafeTransaction($migrate); + } + } + + /** + * Check which datafield types are supported by the new custom variable support + * + * This does not yet account for datafields that are skipped because a custom + * property with the same name already exists + * + * @return void + */ + private function checkMigrateableDatafieldTypes(): void + { + $db = $this->db(); + printf( + "The following datafield types and the corresponding number of datafields" + . " have a supported datatype:\n" + ); + $total = 0; + $query = $this->getDataFieldsMigrationQuery(); + $typeOffset = strlen("Icinga\Module\Director\DataType\DataType"); + foreach ( + $db->select()->from( + ['q' => new DbSelectParenthesis($query->getSelectQuery())], + ['datatype', '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 with a supported datatype: %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 variable 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->getDatafieldsWithCategory() + ); + + if (! empty($skippedFields)) { + $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); + } + + /** + * 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', + ] + ); + } + + /** + * Get datafields with unsupported value type in new custom variable support + * + * A datafield is unsupported unless its datatype is handled explicitly by + * prepareCustomProperties(); keep self::SUPPORTED_DATATYPES in sync with the + * types handled there so migrateable/unmigrateable counts never drift apart. + * + * @return array + */ + private function getDatafieldsWithUnsupportedValuetype() + { + $query = $this->getDataFieldQuery(); + $supportedFilters = []; + foreach (self::SUPPORTED_DATATYPES as $suffix) { + $supportedFilters[] = FilterMatch::where('datatype', "*$suffix"); + } + + $query->addFilter(Filter::not(Filter::matchAny($supportedFilters))); + $query->columns(['varname', 'datatype']); + + return $query->fetchPairs(); + } + + /** + * Get datafields with duplicate names + * + * @return array + */ + private function getDatafieldsWithDuplicateNames(): array + { + $query = $this->getDataFieldQuery(); + $query->columns(['varname' => 'dd.varname', 'count' => 'COUNT(dd.varname)']); + $query->group('dd.varname'); + $query->select()->having('COUNT(dd.varname) > 1'); + + return $query->fetchPairs(); + } + + /** + * 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(); + } + + /** + * Migrate the binding of the datafield-to-object bindings + * + * @param int $datafieldId + * @param UuidInterface $propertyUuid + * + * @return void + */ + private function migrateDatafieldObjectTemplateBinding( + int $datafieldId, + UuidInterface $propertyUuid, + string $varName + ): void { + $db = $this->db(); + $dbAdapter = $db->getDbAdapter(); + $propertyUuidExpr = DbUtil::quoteBinaryCompat($propertyUuid->getBytes(), $dbAdapter); + $objectTypes = ['host', 'service', 'notification', 'command', 'user']; + foreach ($objectTypes as $type) { + $query = $dbAdapter->select()->from(['io' => "icinga_{$type}"], ['uuid']) + ->join(['iof' => "icinga_{$type}_field"], "io.id = iof.{$type}_id", ['is_required', 'var_filter']) + ->where('iof.datafield_id = ?', $datafieldId); + + foreach ($dbAdapter->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + if (! empty($row['var_filter'])) { + echo "[!] Datafield '$varName' has a var_filter set for its icinga_{$type} binding; " + . "var_filter is not supported by the new property system and will not be migrated\n"; + } + + $db->insert( + "icinga_{$type}_property", + [ + 'property_uuid' => $propertyUuidExpr, + "{$type}_uuid" => DbUtil::quoteBinaryCompat( + DbUtil::binaryResult($row['uuid']), + $dbAdapter + ), + 'required' => $row['is_required'], + ] + ); + } + } + } + + /** + * Delete the migrated datafields + * + * Must be called from within a transaction, it performs multiple deletes that + * are only consistent with each other and with the migration when committed + * or rolled back together. + * + * @return void + */ + private function deleteMigratedDataFields(): void + { + if (empty($this->migratedDataFields)) { + return; + } + + $db = $this->db(); + $objectTypes = ['host', 'service', 'notification', 'command', 'user']; + $datafieldIds = array_keys($this->migratedDataFields); + foreach ($objectTypes as $type) { + $db->delete( + "icinga_{$type}_field", + Filter::where('datafield_id', $datafieldIds) + ); + } + + $db->delete( + 'director_datafield', + Filter::where('id', $datafieldIds) + ); + } +} diff --git a/application/controllers/BasketController.php b/application/controllers/BasketController.php index 8d4db034e..913e9bbde 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 === 'CustomVariable') { // 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..c8aba1472 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -2,16 +2,463 @@ 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(): void + { + parent::init(); + + $this->assertPermission('director/admin'); + + $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(): void + { + $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) { + $parent = $this->fetchProperty($parentUuid); + + if ($parent['parent_uuid'] !== null) { + $usedCount = $this->fetchPropertyUsedCount(Uuid::fromBytes($parent['parent_uuid'])); + } else { + $usedCount = $this->fetchPropertyUsedCount($parentUuid); + } + } else { + $usedCount = $this->fetchPropertyUsedCount($uuid); + } + + $property['used_count'] = $usedCount; + + if ($property['value_type'] === 'dynamic-array' || 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) + ->setStoredKeyName($property['key_name']) + ->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) use ($usedCount) { + if ( + $usedCount > 0 + && $form->getPopulatedValue('confirm_rename_change') !== 'y' + ) { + $keyName = $form->getStoredKeyName(); + } else { + $keyName = $form->getValue('key_name'); + } + + Notification::success(sprintf( + $this->translate('Custom variable configuration "%s" has successfully been saved'), + $keyName + )); + + $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'); + } + + $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'); + } + + 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'); + } + + /** + * 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($property['parent_uuid']); + $this->parentUuid = $parentUuid; + $parentProperty = $this->fetchProperty($parentUuid); + if (isset($parentProperty['parent_uuid'])) { + $rootUuid = Uuid::fromBytes($parentProperty['parent_uuid']); + } else { + $rootUuid = $parentUuid; + } + + $uuid = $rootUuid; + } + + $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(): void + { + $uuid = $this->uuid; + $this->addTitleTab($this->translate('Create Field')); + + $parent = $this->fetchProperty($uuid); + $propertyForm = (new CustomVariableForm($this->db, null, true, $uuid)) + ->setHideKeyNameElement($parent['value_type'] === 'fixed-array') + ->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); + if (empty($property)) { + $this->redirectNow('__CLOSE__'); + } + + $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\DbUtil::normalizeRow($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'], 'iup.property_uuid = dp.uuid', []) + ->joinLeft(['icp' => 'icinga_command_property'], 'icp.property_uuid = dp.uuid', []) + ->joinLeft(['inp' => 'icinga_notification_property'], 'inp.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); + } + + /** + * Sets the active tab in the interface based on the provided tab name. + * + * @param string $name The name of the tab to activate. + * + * @return void + */ + protected function setTitleTab(string $name): void + { + $tab = $this->createTabs()->get($name); + + if ($tab !== null) { + $this->getTabs()->activate($name); + } + } + + /** + * Create the tabs for the custom variable page. + * + * @return Tabs + */ + 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/HostController.php b/application/controllers/HostController.php index 66588b840..75799ec85 100644 --- a/application/controllers/HostController.php +++ b/application/controllers/HostController.php @@ -4,19 +4,23 @@ use gipfl\Web\Widget\Hint; use Icinga\Module\Director\Auth\Permission; +use Icinga\Module\Director\Forms\CustomVariableForm; +use Icinga\Module\Director\Forms\CustomVariablesForm; +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\Objects\IcingaObject; use Icinga\Module\Director\Web\Table\ObjectsTableService; +use Icinga\Web\Notification; use ipl\Html\Html; use gipfl\IcingaWeb2\Link; 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 +29,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; @@ -250,11 +253,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 +279,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() + ); } } @@ -406,12 +430,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,12 +461,51 @@ 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_SUBMIT, 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); + $this->customVarFormOnSubmit($form, $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( @@ -455,16 +529,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 +551,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 +572,54 @@ 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_SUBMIT, 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); + $this->customVarFormOnSubmit($form, $host); + $form->setInheritedServiceFrom($from->getObjectName()); + $form->setHostForService($host); + + $this->content()->add( + $form->handleRequest($this->getServerRequest()) + ); + } + + $this->commonForServices(); + } + /** * @throws \Icinga\Exception\NotFoundError */ @@ -511,7 +639,8 @@ public function removesetAction() $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() @@ -538,17 +667,102 @@ public function servicesetserviceAction() $set = $setTemplate; } - $service = IcingaService::load([ + $originalService = IcingaService::load([ 'object_name' => $serviceName, + '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_SUBMIT, 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); + $this->customVarFormOnSubmit($form, $host); + $form->setServiceSet($setTemplate); + $form->setHostForService($host); + + $this->tabs()->activate('services'); + $this->content()->add( + $form->handleRequest($this->getServerRequest()) + ); + } + + $this->commonForServices(); + } + + private function customVarFormOnSubmit(CustomVariablesForm $form, IcingaObject $host): void + { + $form + ->on( + CustomVariablesForm::ON_SUBMIT, + function (CustomVariablesForm $form) use ($host) { + if ($form->varsHasBeenModified()) { + Notification::success( + sprintf( + $this->translate( + "Custom variables have been successfully overriden" + . " for service '%s' on host '%s'" + ), + $form->object->getObjectName(), + $host->getObjectName() + ) + ); + } else { + Notification::success( + $this->translate('There is nothing to change.') + ); + } + + $this->redirectNow(Url::fromRequest()); + } + ); + } + + /** + * @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); diff --git a/application/controllers/SuggestionsController.php b/application/controllers/SuggestionsController.php new file mode 100644 index 000000000..9b993393c --- /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', + Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $this->db->getDbAdapter()) + ); + + $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..1f814ed54 --- /dev/null +++ b/application/controllers/VariablesController.php @@ -0,0 +1,90 @@ +assertPermission('director/admin'); + } + + public function indexAction(): void + { + $this->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'], 'iup.property_uuid = dp.uuid', []) + ->joinLeft(['icp' => 'icinga_command_property'], 'icp.property_uuid = dp.uuid', []) + ->joinLeft(['inp' => 'icinga_notification_property'], 'inp.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(): void + { + $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..b7825471e --- /dev/null +++ b/application/forms/CustomVariableForm.php @@ -0,0 +1,792 @@ +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; + } + + public function setStoredKeyName(string $keyName): self + { + $this->storedKeyName = $keyName; + + return $this; + } + + public function getStoredKeyName(): string + { + return $this->storedKeyName; + } + + public function isPendingRenameConfirmation(): bool + { + return $this->uuid !== null + && $this->storedKeyName !== null + && (int) $this->getPopulatedValue('used_count', 0) > 0 + && $this->getPopulatedValue('confirm_rename_change', '') === '' + && $this->getPopulatedValue('key_name') !== $this->storedKeyName; + } + + public function hasBeenSubmitted(): bool + { + if ($this->isPendingRenameConfirmation()) { + return false; + } + + return parent::hasBeenSubmitted(); + } + + protected function assemble(): void + { + $this->addCsrfCounterMeasure(Session::getSession()->getId()); + $this->addElement('hidden', 'used_count', ['ignore' => true]); + $used = (int) $this->getValue('used_count') > 0; + $pendingRename = $this->isPendingRenameConfirmation(); + + 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', + ['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', + 'sensitive' => 'Sensitive', + 'dynamic-array' => 'Dynamic Array', + 'datalist-strict' => 'Data List Strict', + 'datalist-non-strict' => 'Data List Non Strict', + ]; + + // Fixed-array, fixed-dictionary and dynamic-dictionary may only be used as a + // top-level custom variable; they cannot be nested inside one another. + if ($this->parentUuid === null) { + $types += [ + 'fixed-array' => 'Fixed Array', + 'fixed-dictionary' => 'Fixed Dictionary', + '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')->getAttributes()->set([ + 'title' => $this->translate( + 'This property is used in one or more templates' + . ' and hence the item type cannot be changed.' + ), + 'disabled' => true, + ]); + } + } + + if ($pendingRename) { + $this->addElement('checkbox', 'confirm_rename_change', [ + 'label' => $this->translate('Confirm rename') + ]); + + $this->addHtml(new Callout( + CalloutType::Warning, + Text::create($this->translate( + 'There are objects with this custom variable. Renaming changes the name of the' + . ' custom variable in those objects. And this may break the apply rules. Are you' + . ' sure you want to rename the custom variable?' + )) + )); + } + + $this->addElement('submit', 'submit', [ + 'label' => $this->uuid ? $this->translate('Save') : $this->translate('Add') + ]); + + if ($this->uuid) { + $this->getElement('submit') + ->getWrapper() + ->prepend( + (new ButtonLink( + $this->translate('Delete'), + Url::fromPath( + 'director/customvar/delete', + ['uuid' => $this->uuid->toString()] + ), + null, + ['class' => ['btn-remove']] + ))->openInModal() + ); + } + } + + /** + * Get the datalist options for the field + * + * @return array + */ + private function enumDatalist(): array + { + return $this->db->fetchPairs( + $this->db->select()->from('director_datalist', ['id', 'list_name'])->order('list_name') + ); + } + + /** + * Fetch the datalist for the given ID + * + * @param int $id + * + * @return array + */ + private function fetchDatalist(int $id): array + { + return (array) $this->db->fetchRow( + $this->db->select()->from('director_datalist', ['*']) + ->where('id', $id) + ); + } + + /** + * Fetch the configured categories for the custom variables + * + * @return array + */ + 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\DbUtil::normalizeRow($db->fetchRow($query, [], Zend_Db::FETCH_ASSOC) ?: []); + } + + /** + * Update the custom variable values in the database + * + * @param array $path + * @param array $newPath + * @param array $item + * + * @return void + */ + 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']); + } + + if ( + $this->uuid !== null + && $this->storedKeyName !== null + && $this->getPopulatedValue('confirm_rename_change', '') === 'n' + ) { + $values['key_name'] = $this->storedKeyName; + } + + $this->db->getDbAdapter()->beginTransaction(); + try { + if ($this->uuid === null) { + $this->addNewProperty($values, $datalist, $itemType); + } else { + $this->updateExistingProperty($values, $datalist, $itemType); + } + } catch (Throwable $e) { + $this->db->getDbAdapter()->rollBack(); + + throw $e; + } + + $this->db->getDbAdapter()->commit(); + } + + /** + * Add a new custom variable + * + * @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( + Db\DbUtil::binaryResult($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 + */ + 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) { + $db = $this->db->getDbAdapter(); + + // The old value type is going away, so whatever this property already holds in + // host/service/etc. custom variables no longer matches the new schema and has + // to be cleared, the same way deleting the property outright would clear it. + $cleaner = new CustomVariableValueCleaner($this->db); + if ($this->parentUuid === null) { + $cleaner->deleteStoredValues($dbProperty['key_name']); + } else { + $parentProperty = $this->fetchProperty($this->parentUuid); + $cleaner->removeObjectCustomVars($dbProperty, $parentProperty, true); + $cleaner->removeFromOverrideServiceVars($dbProperty, $parentProperty, true); + } + + // A dictionary can be nested arbitrarily deep (dictionary -> dictionary -> ...), + // and any level of that nesting might itself be a datalist-backed field. Hence, + // any links between datalists and children or grandchildren in the hierarchy must + // also be removed. + $descendantUuids = $this->collectDescendantUuids($this->uuid->getBytes()); + + if (! empty($descendantUuids)) { + $this->db->delete( + 'director_property', + Filter::matchAll(Filter::where( + 'uuid', + Db\DbUtil::quoteBinaryCompat($descendantUuids, $db) + )) + ); + } + + $this->db->delete( + 'director_property_datalist', + Filter::matchAll(Filter::where( + 'property_uuid', + Db\DbUtil::quoteBinaryCompat( + array_merge([$this->uuid->getBytes()], $descendantUuids), + $db + ) + )) + ); + + 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( + Db\DbUtil::binaryResult($datalist['uuid']), + $this->db->getDbAdapter() + ), + ]); + } + } + } else { + if (str_starts_with($valueType, 'datalist-') && ! empty($datalist)) { + $this->relinkDatalist($this->uuid->getBytes(), $datalist); + } + + if ($itemType !== '' && ($valueType === 'dynamic-array' || str_starts_with($valueType, 'datalist-'))) { + $this->updateItemType($this->uuid->getBytes(), $itemType); + } + } + } 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())) + ); + } + + /** + * Point the given property at the given datalist, replacing any existing link + * + * @param string $propertyUuid Raw binary UUID of the property + * @param array $datalist Datalist row, as returned by fetchDatalist() + * + * @return void + */ + private function relinkDatalist(string $propertyUuid, array $datalist): void + { + $db = $this->db->getDbAdapter(); + $quotedPropertyUuid = Db\DbUtil::quoteBinaryCompat($propertyUuid, $db); + $newListUuid = Db\DbUtil::binaryResult($datalist['uuid']); + + $linkedListUuid = Db\DbUtil::binaryResult($this->db->fetchOne( + $this->db->select() + ->from('director_property_datalist', ['list_uuid']) + ->where('property_uuid', $quotedPropertyUuid) + )); + + if ($linkedListUuid === $newListUuid) { + return; + } + + $this->db->delete( + 'director_property_datalist', + Filter::where('property_uuid', $quotedPropertyUuid) + ); + + $this->db->insert('director_property_datalist', [ + 'property_uuid' => $quotedPropertyUuid, + 'list_uuid' => Db\DbUtil::quoteBinaryCompat($newListUuid, $db), + ]); + } + + /** + * Update the value type of the item type child property for the given parent property + * + * @param string $parentUuid Raw binary UUID of the parent property + * @param string $itemType New item value type + * + * @return void + */ + private function updateItemType(string $parentUuid, string $itemType): void + { + $db = $this->db->getDbAdapter(); + + $this->db->update( + 'director_property', + ['value_type' => $itemType], + Filter::matchAll( + Filter::where('parent_uuid', Db\DbUtil::quoteBinaryCompat($parentUuid, $db)), + Filter::where('key_name', '0') + ) + ); + } + + /** + * Recursively collect the raw binary UUIDs of all descendants (children, + * grandchildren, ...) of the property with the given raw binary UUID. + * + * @param string $uuid Raw binary UUID of the property to start from + * + * @return string[] Raw binary UUIDs of all descendants, not including $uuid itself + */ + private function collectDescendantUuids(string $uuid): array + { + $db = $this->db->getDbAdapter(); + $descendants = []; + $parents = [$uuid]; + + while (! empty($parents)) { + $children = $db->fetchCol( + $db->select() + ->from('director_property', ['uuid']) + ->where('parent_uuid IN (?)', Db\DbUtil::quoteBinaryCompat($parents, $db)) + ); + $children = array_map([Db\DbUtil::class, 'binaryResult'], $children); + + $descendants = array_merge($descendants, $children); + $parents = $children; + } + + return $descendants; + } + + /** + * Update the used custom variable names in the database + * + * @param string $storedKeyName + * @param mixed $keyName + * + * @return void + */ + private function updateUsedCustomVarNames(string $storedKeyName, mixed $keyName): void + { + $db = $this->db->getDbAdapter(); + $cleaner = new CustomVariableValueCleaner($this->db); + + if (! $this->parentUuid) { + $root = $this->fetchProperty($this->uuid); + $oldPath = null; + $newPath = null; + } else { + $parent = $this->fetchProperty($this->parentUuid); + [$root, $oldPath] = $cleaner->resolveRootProperty(['key_name' => $storedKeyName], $parent); + $newPath = array_slice($oldPath, 0, -1); + $newPath[] = $keyName; + } + + $objectTypes = ['host', 'service', 'notification', 'command', 'user']; + + foreach ($objectTypes as $objectType) { + // Match by varname, not property_uuid, root key_names are unique and property_uuid + // is only ever an optional hint that isn't reliably populated on every stored row. + $objectCustomVars = $db->fetchAll( + $db->select() + ->from(['ihv' => "icinga_{$objectType}_var"], []) + ->columns([ + "{$objectType}_id", + 'varname', + 'varvalue' + ]) + ->where('varname = ?', $root['key_name']), + [], + PDO::FETCH_ASSOC + ); + + if (! $this->parentUuid) { + foreach ($objectCustomVars as $objectCustomVar) { + $this->db->update( + "icinga_{$objectType}_var", + ['varname' => $keyName], + Filter::matchAll( + Filter::where('varname', $root['key_name']), + Filter::where("{$objectType}_id", $objectCustomVar["{$objectType}_id"]) + ) + ); + } + + continue; + } + + foreach ($objectCustomVars as $objectCustomVar) { + $varValue = json_decode($objectCustomVar['varvalue'], true); + if ($root['value_type'] !== 'dynamic-dictionary') { + $this->updateObjectCustomVars($oldPath, $newPath, $varValue); + } else { + foreach ($varValue as $key => $value) { + if (is_array($value)) { + $this->updateObjectCustomVars($oldPath, $newPath, $value); + } + + $varValue[$key] = $value; + } + } + + $this->db->update( + "icinga_{$objectType}_var", + ['varvalue' => json_encode($varValue)], + Filter::matchAll( + Filter::where('varname', $root['key_name']), + 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..998fbb4de --- /dev/null +++ b/application/forms/CustomVariablesForm.php @@ -0,0 +1,628 @@ +addAttributes(Attributes::create(['class' => 'custom-variables-form'])); + } + + /** + * Check if the custom properties have been modified + * + * @return bool + */ + public function varsHasBeenModified(): bool + { + return $this->varsHasBeenModified; + } + + /** + * Set the custom variable Uuid strings that were newly added to the form + * + * @param array $uuids + * + * @return $this + */ + public function setAddedVarUuids(array $uuids): static + { + $this->addedVarUuids = $uuids; + + return $this; + } + + /** + * Set the custom variable Uuid strings that were marked required this session + * + * @param array $uuids + * + * @return $this + */ + public function setRequiredVarUuids(array $uuids): static + { + $this->requiredVarUuids = $uuids; + + return $this; + } + + protected function assemble(): void + { + $this->addCsrfCounterMeasure(Session::getSession()->getId()); + + $properties = $this->objectProperties; + if ($this->object->isTemplate()) { + // Templates define the schema, they don't fill it in, so required never applies here. + // required_current keeps the real flag around so the row can still show it as a toggle. + foreach ($properties as &$property) { + $property['required_current'] = $property['required'] ?? false; + $property['required'] = false; + } + + unset($property); + } + + $dictionary = (new Dictionary( + 'properties', + $properties, + ['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') + ]); + + $addedUuidsContainer = new HtmlElement( + 'div', + Attributes::create(['id' => 'added-var-uuids', 'class' => 'added-var-uuids', 'tabindex' => -1]) + ); + + $addedUuidsElement = $this->createElement( + 'hidden', + 'addedVarUuids', + [ + 'value' => implode(',', $this->addedVarUuids) + ] + ); + + $this->registerElement($addedUuidsElement); + $addedUuidsContainer->addHtml($addedUuidsElement); + + $requiredUuidsContainer = new HtmlElement( + 'div', + Attributes::create(['id' => 'required-var-uuids', 'class' => 'required-var-uuids', 'tabindex' => -1]) + ); + + $requiredUuidsElement = $this->createElement( + 'hidden', + 'requiredVarUuids', + [ + 'value' => implode(',', $this->requiredVarUuids) + ] + ); + + $this->registerElement($requiredUuidsElement); + $requiredUuidsContainer->addHtml($requiredUuidsElement); + + $this->addElement($this->duplicateSubmitButton($saveButton)); + $this->addElement($dictionary); + if ($this->hasBeenSent()) { + $dictionary->ensureAssembled(); + } + + $this->addHtml($addedUuidsContainer); + $this->addHtml($requiredUuidsContainer); + $this->registerElement($saveButton); + + $removedItems = $dictionary->getItemsToRemove(); + $removedUuids = []; + foreach ($removedItems as $removedItem) { + $removedUuids[] = Uuid::fromBytes($this->objectProperties[$removedItem]['uuid'])->toString(); + } + + $removedUuids = array_diff($removedUuids, $this->addedVarUuids); + + if (! empty($removedUuids)) { + $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($removedUuids) + ), + count($removedUuids) + ) + )) + ); + } + + $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); + } + + /** + * Assert that a host has been set whenever an override is requested + * + * @return void + * + * @throws LogicException + */ + private function assertOverrideHostIsSet(): void + { + if ($this->isOverrideServiceVars() && $this->host === null) { + throw new LogicException( + 'CustomVariablesForm needs setHostForService() to be called before overriding service variables' + ); + } + } + + 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) + ]); + } + + /** + * Build a standalone DictionaryItem row for use in a multipart update. + * + * @param array $propertyData Row data as returned by getObjectCustomProperties() + * @param int $index The slot index this item occupies + * + * @return BaseHtmlElement + */ + public function prepareNewPropertyRow(array $propertyData, int $index): BaseHtmlElement + { + $this->ensureAssembled(); + /** @var Dictionary $dictionary */ + $dictionary = $this->getElement('properties'); + + if ($this->object->isTemplate()) { + // Only reachable from a template's own tab, which never enforces required. + $propertyData['required_current'] = $propertyData['required'] ?? false; + $propertyData['required'] = false; + } + + if ($propertyData['allow_removal']) { + $removeButton = $dictionary->createElement('submitButton', 'remove_' . $index, [ + 'label' => 'Remove Item', + 'class' => ['remove-property'], + 'formnovalidate' => true + ]); + $dictionary->registerElement($removeButton); + } else { + $removeButton = null; + } + + $propertyData['uuid'] = DbUtil::binaryResult($propertyData['uuid']); + $newItem = new DictionaryItem((string) $index, $propertyData); + + $this->decorate($newItem); + if ($removeButton !== null) { + $newItem->setRemoveButton($removeButton); + } + + $dictionary->registerElement($newItem); + + $newItem->populate(DictionaryItem::prepare($propertyData)); + + return $newItem; + } + + /** + * Filter empty values from array + * + * @param array $array + * + * @return array + */ + public static function filterEmpty(array $array): array + { + // Lists (sequential int keys) are positional, e.g., a fixed-array's own value. So we + // never drop individual elements there, only decide to keep the list as-is or drop it + // entirely if everything in it is empty. Otherwise, element removal would shift later items + // into earlier slots. This holds at any nesting depth, not just the outermost array. + if (array_is_list($array)) { + foreach ($array as $item) { + $checkedItem = is_array($item) ? self::filterEmpty($item) : $item; + if (! self::isValueUnset($checkedItem)) { + return $array; + } + } + + return []; + } + + return array_filter( + array_map(function ($item) { + if (! is_array($item)) { + return $item; + } + + return self::filterEmpty($item); + }, $array), + function ($item) { + return ! self::isValueUnset($item); + } + ); + } + + /** + * Assert that a new custom variable may be attached to $this->object + * + * Custom variables are only meant to be attached directly to a + * template. + * + * @return void + * + * @throws LogicException + */ + private function assertCanAttachNewVariable(): void + { + if (! $this->object->isTemplate()) { + throw new LogicException(sprintf( + 'Custom Variables can only be attached directly to a template, got %s', + $this->object->getObjectName() + )); + } + } + + /** + * Assert that the required flag of a property attachment may be changed on $this->object + * + * The required toggle is only ever rendered for a template's own directly + * attached properties, this is a defensive backstop for that same rule. + * + * @return void + * + * @throws LogicException + */ + private function assertCanEditRequiredFlag(): void + { + if (! $this->object->isTemplate()) { + throw new LogicException(sprintf( + 'The required flag can only be changed on the template it was set on, got %s', + $this->object->getObjectName() + )); + } + } + + /** + * Whether the given value should be treated as unset + * + * @param mixed $value + * + * @return bool + */ + public static function isValueUnset(mixed $value): bool + { + if (is_bool($value) || $value === 0 || $value === 0.0 || $value === '0') { + return false; + } + + return empty($value); + } + + protected function onSuccess(): void + { + $this->assertOverrideHostIsSet(); + + // The property (re)attachments below, the vars removal and the final + // store() are multiple separate writes that only make sense together; + // a failure partway through must not leave them half-applied. + $this->object->getConnection()->runFailSafeTransaction(function () { + $this->persistPropertyChanges(); + }); + } + + /** + * Apply the submitted property values and attachments and persist them + * + * @return void + */ + private function persistPropertyChanges(): void + { + $vars = $this->object->vars(); + + /** @var Dictionary $propertiesElement */ + $propertiesElement = $this->getElement('properties'); + $values = $propertiesElement->getDictionary(); + $requiredFlags = $propertiesElement->getRequiredFlags(); + $itemsToRemove = $propertiesElement->getItemsToRemove(); + $type = $this->object->getShortTableName(); + $db = $this->object->getDb(); + $itemsToRemoveUuids = []; + $overrideVars = [] ; + $isOverrideServiceVars = $this->isOverrideServiceVars(); + if ($isOverrideServiceVars) { + $overrideVars = (array) $this->host->getOverriddenServiceVars($this->object->getObjectName()); + } + + foreach ($this->objectProperties as $key => $property) { + $propertyUuid = Uuid::fromBytes($property['uuid']); + if (isset($property['removed'])) { + $itemsToRemoveUuids[] = DbUtil::quoteBinaryCompat($property['uuid'], $db); + continue; + } + + if (in_array($key, $itemsToRemove)) { + $itemsToRemoveUuids[] = DbUtil::quoteBinaryCompat($property['uuid'], $db); + $this->varsHasBeenModified = true; + + continue; + } + + $hasSubmittedValue = array_key_exists($key, $values); + $value = $hasSubmittedValue ? $values[$key] : null; + + if (is_array($value) && ! empty($value)) { + if ($property['value_type'] === 'dynamic-dictionary') { + // Preserve outer keys; only filter empty sub-field values within each entry + $value = array_map(function ($entry) { + if (! is_array($entry)) { + return $entry; + } + + $filtered = self::filterEmpty($entry); + + return empty($filtered) ? (object) [] : $filtered; + }, $value); + } else { + $value = self::filterEmpty($value); + } + } + + if (isset($property['new'])) { + $this->assertCanAttachNewVariable(); + $this->varsHasBeenModified = true; + $this->object->getConnection()->insert( + "icinga_$type" . '_property', + [ + $type . '_uuid' => DbUtil::quoteBinaryCompat($this->object->uuid, $db), + 'property_uuid' => DbUtil::quoteBinaryCompat($propertyUuid->getBytes(), $db), + 'required' => ($requiredFlags[$key] ?? ($property['required'] ?? false)) ? 'y' : 'n' + ] + ); + } elseif ( + array_key_exists($key, $requiredFlags) + && $requiredFlags[$key] !== (bool) ($property['required'] ?? false) + ) { + $this->assertCanEditRequiredFlag(); + $objectWhere = $db->quoteInto( + $type . '_uuid = ?', + DbUtil::quoteBinaryCompat($this->object->uuid, $db) + ); + $propertyWhere = $db->quoteInto( + 'property_uuid = ?', + DbUtil::quoteBinaryCompat($propertyUuid->getBytes(), $db) + ); + $db->update( + "icinga_$type" . '_property', + ['required' => $requiredFlags[$key] ? 'y' : 'n'], + $objectWhere . ' AND ' . $propertyWhere + ); + $this->varsHasBeenModified = true; + } + + if (! $hasSubmittedValue) { + // Fully inherited and untouched, leave the object's own vars alone. + continue; + } + + if (self::isValueUnset($value)) { + if ($isOverrideServiceVars) { + if (isset($overrideVars[$key])) { + unset($overrideVars[$key]); + $this->varsHasBeenModified = true; + } + } else { + $vars->set($key, null); + } + } else { + if ($isOverrideServiceVars) { + $overrideVars[$key] = $value; + } else { + $vars->set($key, $value); + } + } + + if ( + ! $isOverrideServiceVars + && $vars->get($key) + && $vars->get($key)->getUuid() === null + && $vars->hasBeenModified() + && 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 (?)', $itemsToRemoveUuids) + ); + + 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 = ?', + DbUtil::quoteBinaryCompat($this->object->get('uuid'), $db) + ); + $db->delete( + 'icinga_' . $type . '_property', + $propertyWhere . ' AND ' . $objectWhere + ); + } + + if ($this->isOverrideServiceVars()) { + $object = $this->host; + $object->overrideServiceVars($this->object->getObjectName(), (object) $overrideVars); + $this->varsHasBeenModified = $object->hasBeenModified(); + if ($object->hasBeenModified()) { + DirectorActivityLog::logModification($object, $this->object->getConnection()); + } + + $object->store($this->object->getConnection()); + } else { + $object = $this->object; + if ($this->varsHasBeenModified) { + 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..9ea8b2504 --- /dev/null +++ b/application/forms/DeleteCustomVariableForm.php @@ -0,0 +1,234 @@ +cleaner = new CustomVariableValueCleaner($db); + } + + /** + * Fetch the give custom variable usage in templates + * + * @return array + */ + private function fetchCustomVarUsage(): array + { + $db = $this->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']; + } + + $uuid = DbUtil::quoteBinaryCompat($uuid, $db); + + $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); + + $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 variable "%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->cleaner->fetchProperty(Uuid::fromBytes($this->parent['parent_uuid']))['key_name'] + ); + } else { + $info = sprintf($this->translate( + 'Deleting this field from custom variable "%s" will remove this field in' + . ' the corresponding custom variable 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 variable will remove it 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 variable 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->addCsrfCounterMeasure(Session::getSession()->getId()); + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Delete'), + 'class' => 'btn-remove' + ]); + } + + protected function onSuccess(): void + { + $uuid = $this->property['uuid']; + $db = $this->db; + $prop = $this->property; + $cleaner = $this->cleaner; + + // A dictionary can be nested arbitrarily deep (dictionary -> dictionary -> ...), + // and any level of that nesting might itself be a datalist-backed field. Hence, + // any links between datalists and children or grandchildren in the hierarchy must + // also be removed. + $allUuids = array_merge([$uuid], $this->collectDescendantUuids($uuid)); + $quotedAllUuids = DbUtil::quoteBinaryCompat($allUuids, $this->db->getDbAdapter()); + + $db->runFailSafeTransaction(function () use ($db, $prop, $quotedAllUuids, $cleaner) { + $db->delete('director_property_datalist', Filter::where('property_uuid', $quotedAllUuids)); + + $cleaner->removeObjectCustomVars($prop, $this->parent); + $cleaner->removeFromOverrideServiceVars($prop, $this->parent); + + if (empty($this->parent)) { + $cleaner->deleteStoredValues($prop['key_name']); + } + + $db->delete('director_property', Filter::where('uuid', $quotedAllUuids)); + }); + } + + /** + * Recursively collect the raw binary UUIDs of all descendants (children and + * grandchildren) of the property with the given raw binary UUID. + * + * @param string $uuid Raw binary UUID of the property to start from + * + * @return string[] Raw binary UUIDs of all descendants, not including $uuid itself + */ + private function collectDescendantUuids(string $uuid): array + { + $dba = $this->db->getDbAdapter(); + $descendants = []; + $parents = [$uuid]; + + while (! empty($parents)) { + $children = $dba->fetchCol( + $dba->select() + ->from('director_property', ['uuid']) + ->where('parent_uuid IN (?)', DbUtil::quoteBinaryCompat($parents, $dba)) + ); + $children = array_map([DbUtil::class, 'binaryResult'], $children); + + $descendants[] = $children; + $parents = $children; + } + + return array_merge(...$descendants); + } +} diff --git a/application/forms/DictionaryElements/Dictionary.php b/application/forms/DictionaryElements/Dictionary.php new file mode 100644 index 000000000..48534d648 --- /dev/null +++ b/application/forms/DictionaryElements/Dictionary.php @@ -0,0 +1,315 @@ + '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; + + /** @var bool Whether dictionary is a child dictionary */ + private bool $isChild = false; + + public function __construct(string $name, array $items, $attributes = null) + { + $this->items = $items; + + parent::__construct($name, $attributes); + } + + /** + * Set if the dictionary item could be removed + * + * @param bool $allow + * + * @return $this + */ + public function setAllowItemRemoval(bool $allow = false): static + { + $this->allowItemRemoval = $allow; + + return $this; + } + + /** + * Set whether the dictionary is a child dictionary. + * + * @param bool $isChild + * + * @return $this + */ + public function setIsChild(bool $isChild = true): static + { + $this->isChild = $isChild; + + return $this; + } + + protected function assemble(): void + { + $expectedCount = (int) $this->getPopulatedValue('item-count', 0); + $count = 0; + + // Load previously removed items from the hidden field (no session) + $removedItemsPopulated = $this->getPopulatedValue('items_removed', ''); + $removedItems = $removedItemsPopulated !== '' + ? array_fill_keys(explode(', ', $removedItemsPopulated), true) + : []; + + if ($this->allowItemRemoval) { + 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); + if (isset($removedValue['name'])) { + $clearedItemName = $removedValue['name']; + if (isset($this->items[$clearedItemName])) { + $removedItems[$clearedItemName] = true; + } + } + + $this->clearPopulatedValue('items_removed'); + $this->clearPopulatedValue($remove->getName()); + $this->clearPopulatedValue($count); + $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((string) $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'); + $itemCountInput = $this->createElement('hidden', 'item-count', ['ignore' => true, 'value' => $count]); + $this->registerElement($itemCountInput); + $this->addHtml(Html::tag('div', ['id' => $this->getName() . '-item-count'], $itemCountInput)); + + + $newVarSlot = Html::tag('div', ['id' => 'new-var-slot-' . $count]); + + if ($this->allowItemRemoval) { + $this->addHtml($newVarSlot); + } + + if ($count === 0) { + if (! $this->isChild) { + $message = $this->translate('No custom variables have been added to the template yet'); + } else { + $message = $this->translate('No fields configured'); + } + + if ($message !== '') { + $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) + { + 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 number of rendered DictionaryItem children + * + * @return int + */ + public function getItemCount(): int + { + $count = 0; + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof DictionaryItem) { + $count++; + } + } + + return $count; + } + + /** + * Whether every child item is unchanged from what it started with + * + * @return bool + */ + public function allChildrenUnchanged(): bool + { + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof DictionaryItem && ! $element->ensureAssembled()->isUnchanged()) { + return false; + } + } + + return true; + } + + /** + * Get the dictionary value + * + * @param bool $applyUnchangedDefaults Apply the untouched-child type defaults, see DictionaryItem::getItem() + * + * @return array + */ + public function getDictionary(bool $applyUnchangedDefaults = true): array + { + $items = []; + + /** @var DictionaryItem $element */ + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof DictionaryItem) { + $item = $element->ensureAssembled()->getItem($applyUnchangedDefaults); + if (isset($item['name']) && array_key_exists('value', $item)) { + $items[$item['name']] = $item['value']; + } + } + } + + return $items; + } + + /** + * Whether any child is inherited from an ancestor + * + * Fixed-array/fixed-dictionary inherit as a whole, so one inherited child + * means the whole thing came from a parent. + * + * @return bool + */ + public function hasInheritedValue(): bool + { + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof DictionaryItem && $element->hasInheritedValue()) { + return true; + } + } + + return false; + } + + /** + * Get the submitted required flags, keyed by property name + * + * Only carries an entry for items that actually render the required toggle, + * i.e. properties directly attached to the object being edited. + * + * @return bool[] + */ + public function getRequiredFlags(): array + { + $flags = []; + + /** @var DictionaryItem $element */ + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof DictionaryItem) { + $item = $element->ensureAssembled()->getItem(); + if (isset($item['name']) && array_key_exists('required', $item)) { + $flags[$item['name']] = $item['required']; + } + } + } + + return $flags; + } +} diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php new file mode 100644 index 000000000..ebd380291 --- /dev/null +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -0,0 +1,696 @@ + ['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 getDb(): Zend_Db_Adapter_Abstract + { + return Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); + } + + private static function fetchItemType(UuidInterface $uuid): ?string + { + $db = static::getDb(); + $query = $db->select() + ->from( + ['dp' => 'director_property'], + ['value_type' => 'dp.value_type'] + ) + ->where('dp.parent_uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + $itemType = $db->fetchOne($query); + + return $itemType === false ? null : $itemType; + } + + /** + * Fetch datalist entries for a given property uuid. + * + * @param UuidInterface $uuid + * + * @return array + */ + private static function fetchDataListEntries(UuidInterface $uuid): array + { + $db = static::getDb(); + $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 + { + if (empty($this->fields)) { + return; + } + + $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->addElement('checkbox', 'item_required_' . $this->getName(), [ + 'label' => $this->translate('Required in inheriting objects'), + 'class' => 'item-required-checkbox', + 'value' => ($this->fields['required_current'] ?? false) ? 'y' : 'n' + ]); + $this->addHtml(new HtmlElement( + 'div', + null, + $this->removeButton + )); + } + + if ($label === null) { + $label = $this->getElement('name')->getValue() ?? ''; + } + + $uuid = Uuid::fromBytes($this->fields['uuid']); + // Pass down this item's own stored value, so each child gets its current value + // too. Without this, a nested sensitive field has nothing to fall back on when + // it comes back blank. + $children = static::fetchChildrenItems( + $uuid, + $this->fields['value_type'] ?? '', + ['value' => $this->fields['value'] ?? []] + ); + $inherited = $this->getElement('inherited')->getValue(); + $inheritedFrom = $this->getElement('inherited_from')->getValue(); + + $placeholder = ''; + if ($inherited) { + $placeholder = $inherited . ' (' . sprintf($this->translate('Inherited from %s'), $inheritedFrom) . ')'; + } + + if ($type === 'number') { + $this->addElement( + 'number', + $valElementName, + [ + 'label' => $label . ' (Number)', + 'placeholder' => $placeholder, + 'step' => 'any' + ] + ); + } elseif ($type == 'bool') { + $this->addElement( + new IplBoolean( + $valElementName, + ['label' => $label, 'placeholder' => $placeholder] + ) + ); + } elseif ($type === 'sensitive') { + $this->addElement( + new SensitiveElement( + $valElementName, + [ + 'label' => $label . ' (Sensitive)', + 'autocomplete' => 'off' + ] + ) + ); + } 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], + $this->fields['value'] ?? [] + ))->setLabel($label . ' (Dictionary)')->setUuid(Uuid::fromBytes($this->fields['uuid']))); + } else { + $this->addElement( + 'text', + $valElementName, + [ + 'label' => $label . ' (' . ucfirst($type) . ')', + 'placeholder' => $placeholder + ] + ); + } + + if ($this->fields['required'] ?? false) { + $valueElement = $this->getElement($valElementName); + + // fixed-array/fixed-dictionary push 'inherited' onto their children + // instead of carrying it themselves, so check there. + $isInherited = ($type === 'fixed-array' || $type === 'fixed-dictionary') + ? ($valueElement instanceof Dictionary && $valueElement->hasInheritedValue()) + : ! empty($inherited); + + if (! $isInherited) { + $this->markValueRequired($valueElement); + } + } + } + + /** + * Mark the given value element required + * + * A Dictionary/NestedDictionary always has a value thanks to its own hidden + * bookkeeping fields, so a plain setRequired() would never trigger for one. + * Its real content is checked separately, through a validator. + * + * @param FormElement $element + * + * @return void + */ + private function markValueRequired(FormElement $element): void + { + $element->setRequired(true); + + if (! ($element instanceof Dictionary || $element instanceof NestedDictionary)) { + return; + } + + $element->addValidators([ + new CallbackValidator(function ($value, CallbackValidator $validator) use ($element) { + // Ignore the untouched-child type defaults (0, 'n', ...), or a + // blank fixed-array would read as filled in. + $realValue = CustomVariablesForm::filterEmpty($element->getDictionary(false)); + if (! CustomVariablesForm::isValueUnset($realValue)) { + return true; + } + + $validator->addMessage($this->translate('This field is required.')); + + return false; + }) + ]); + } + + /** + * Whether this item's own value is inherited from an ancestor + * + * @return bool + */ + public function hasInheritedValue(): bool + { + return ! empty($this->ensureAssembled()->getElement('inherited')->getValue()); + } + + public function populate($values) + { + if (empty($values)) { + return parent::populate($values); + } + + if ( + $values['type'] === 'datalist-non-strict' + && self::fetchItemType(Uuid::fromBytes($this->fields['uuid'])) === 'string' + ) { + $datalistEntries = array_flip(self::fetchDataListEntries(Uuid::fromBytes($this->fields['uuid']))); + $varValue = is_string($values['var'] ?? null) ? $values['var'] : ''; + $values['var'] = $varValue; + + if (isset($datalistEntries[$varValue])) { + $values['var-search'] = $datalistEntries[$varValue]; + $values['var-label'] = $varValue; + } else { + $values['var-search'] = $varValue; + } + } + + return 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 = is_string($property['value'] ?? null) ? $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; + } + } elseif ($property['value_type'] === 'sensitive') { + // Send the DUMMYPASSWORD placeholder, not the real secret. The field itself + // can't tell a stored secret apart from a value the user just typed, so we + // mask it here, before it reaches the field. + $values['var'] = ($property['value'] ?? '') !== '' ? SensitiveElement::DUMMYPASSWORD : ''; + // Never write the inherited secret itself into the 'inherited' hidden field's + // DOM value; only its presence is needed downstream (fixed-array default-value + // logic in getItem()), not its content. + $values['inherited'] = ($property['inherited'] ?? '') !== '' ? '1' : ''; + $values['inherited_from'] = $property['inherited_from'] ?? ''; + } else { + $values['var'] = $property['value'] ?? ''; + $values['inherited'] = $property['inherited'] ?? ''; + $values['inherited_from'] = $property['inherited_from'] ?? ''; + } + + return $values; + } + + /** + * Fetch children items of the given parent item + * + * @param UuidInterface $parentUuid + * @param string $parentType + * @param array $values + * + * @return array + */ + private static function fetchChildrenItems( + UuidInterface $parentUuid, + string $parentType, + array $values = [] + ): array { + $db = static::getDb(); + + $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); + + if ($parentType === 'fixed-array') { + // For a fixed array, key_name is the item's position, not a label, so order by + // it numerically instead of the lexicographic "children"/key_name order above + usort($propertyItems, fn($a, $b) => (int) $a['key_name'] <=> (int) $b['key_name']); + } + + 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; + } + + return self::mergeChildValues($propertyItems, $parentType, $values); + } + + /** + * Add values to a set of child item definitions that were already fetched + * + * Used by fetchChildrenItems() for a single parent, and by NestedDictionary, which + * reuses the same child definitions for every entry of a dynamic-dictionary and + * merges in each entry's own value. + * + * @param array $propertyItems Children item definitions, keyed by their position + * @param string $parentType + * @param array $values + * + * @return array Children item definitions keyed by key_name, each carrying its own value + */ + public static function mergeChildValues(array $propertyItems, string $parentType, array $values): array + { + $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; + } + + /** + * Whether this item's own value is unchanged from what it started with + * + * Lets a parent fixed-array/fixed-dictionary tell an untouched child from an + * edited one. Resubmitting an already stored value must never count as a touch. + * + * @return bool + */ + public function isUnchanged(): bool + { + $itemValue = $this->getElement('var'); + + if ($itemValue instanceof Dictionary) { + return $itemValue->allChildrenUnchanged(); + } + + if ($itemValue instanceof SensitiveElement && $itemValue->wasSubmittedUnchanged()) { + return true; + } + + $submitted = $itemValue->getValue(); + if ($submitted === '') { + $submitted = null; + } + + $stored = $this->fields['value'] ?? null; + if ($stored === '') { + $stored = null; + } + + return $submitted === $stored; + } + + /** + * Get the dictionary item value + * + * @param bool $applyUnchangedDefaults Apply the untouched-child type default (0, 'n', ...). + * Storage needs it, a required check does not. + * + * @return DictionaryItemDataType + */ + public function getItem(bool $applyUnchangedDefaults = true): array + { + $values = ['name' => $this->getElement('name')->getValue()]; + $itemValue = $this->getElement('var'); + if ($itemValue instanceof NestedDictionary or $itemValue instanceof Dictionary) { + // Fixed-array/fixed-dictionary is all or nothing, one edited child rewrites + // the whole thing. A top level property with nothing touched has nothing + // to save, so leave it alone. A nested one must still report its value + // since its own parent needs it to build its value. + $isTopLevelProperty = empty($this->getElement('parent_type')->getValue()); + $isUntouched = $isTopLevelProperty + && $itemValue instanceof Dictionary + && $itemValue->allChildrenUnchanged(); + + if (! $isUntouched) { + $values['value'] = $itemValue->getDictionary($applyUnchangedDefaults); + + 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 { + $type = $this->getElement('type')->getValue() ?? ''; + $parentType = $this->getElement('parent_type')->getValue(); + + if (empty($parentType) && ! empty($this->getElement('inherited')->getValue())) { + $values['value'] = $itemValue->getValue(); + } else { + $defaultValue = null; + + // Only fall back to the type default if this item was never touched. + // A field cleared on purpose must stay cleared, not bounce back to it. + if ( + $applyUnchangedDefaults + && ($parentType === 'fixed-array' || $parentType === 'fixed-dictionary') + && $this->isUnchanged() + ) { + match ($type) { + 'string', 'sensitive' => $defaultValue = '', + 'number' => $defaultValue = 0, + 'bool' => $defaultValue = 'n', + 'fixed-array', 'dynamic-array' => $defaultValue = [], + 'datalist-strict', 'datalist-non-strict' => $defaultValue = + self::fetchItemType(Uuid::fromBytes($this->fields['uuid'])) === 'string' ? '' : [], + default => $defaultValue = null + }; + } + + $values['value'] = $itemValue->getValue() ?? $defaultValue; + } + + // If a sensitive field still has the DUMMYPASSWORD placeholder, keep the old + // secret. A stored value of "0" is still a real, previously-set secret, not + // an absent one - only null/'' (never actually set, or explicitly cleared) + // skip the restore. + if ( + $type === 'sensitive' + && $itemValue instanceof SensitiveElement + && $itemValue->wasSubmittedUnchanged() + && $this->fields['value'] !== null + && $this->fields['value'] !== '' + ) { + $values['value'] = $this->fields['value']; + } + } + + $itemName = $this->getName(); + $markForRemovalElement = 'delete-' . $itemName; + if ($this->hasElement($markForRemovalElement)) { + $values['delete'] = $this->getElement($markForRemovalElement)->getValue(); + } + + if ($this->hasElement('item_required_' . $itemName)) { + $values['required'] = $this->getElement('item_required_' . $itemName)->getValue() === 'y'; + } + + return $values; + } +} diff --git a/application/forms/DictionaryElements/NestedDictionary.php b/application/forms/DictionaryElements/NestedDictionary.php new file mode 100644 index 000000000..58168cd44 --- /dev/null +++ b/application/forms/DictionaryElements/NestedDictionary.php @@ -0,0 +1,230 @@ + ['nested-dictionary', 'nested-fieldset']]; + + public const UNDEFINED_KEY = '__undefined__'; + + /** @var array Nested dictionary items */ + protected $nestedItems = []; + + /** @var UuidInterface Uuid of the dictionary */ + private UuidInterface $uuid; + + /** @var array{inherited_from: string, value: array} Inherited value */ + protected array $inheritedValue; + + /** @var array Stored value of each entry, keyed by entry key */ + private array $entryValues; + + public function __construct( + string $name, + array $nestedItems, + array $inheritedValues, + array $entryValues = [], + $attributes = null + ) { + $this->inheritedValue = $inheritedValues; + $this->nestedItems = $nestedItems; + $this->entryValues = $entryValues; + + parent::__construct($name, $attributes); + } + + /** + * Set the Uuid of the nested dictionary property + * + * @param UuidInterface $uuid + * + * @return $this + */ + public function setUuid(UuidInterface $uuid) + { + $this->uuid = $uuid; + + return $this; + } + + 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()) { + $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) { + $this->populate([$i - 1 => $this->getPopulatedValue($i) ?? []]); + } + } else { + $newCount++; + } + + $count++; + } + + $addButton = $this->createElement('submitButton', 'add_item', [ + 'label' => $this->translate('Add Item'), + 'class' => ['add-item'], + 'formnovalidate' => true + ]); + + if (empty($this->nestedItems)) { + $addButton->addAttributes([ + 'disabled' => true, + 'title' => $this->translate('No fields have been configured for the dictionary'), + ]); + } + + $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++) { + // Find this row's stored value by its entry key. $nestedItems is shared by + // every row and holds no value of its own, so a sensitive field needs this + // to fall back on when it comes back as the DUMMYPASSWORD placeholder. + $populatedRow = $this->getPopulatedValue($i); + $entryKey = is_array($populatedRow) ? ($populatedRow['key'] ?? null) : null; + $entryValue = $entryKey !== null ? ($this->entryValues[$entryKey] ?? []) : []; + + $items = empty($entryValue) + ? $this->nestedItems + : DictionaryItem::mergeChildValues($this->nestedItems, 'dynamic-dictionary', ['value' => $entryValue]); + + $nestedDictionaryProperty = new NestedDictionaryItem($i, $items); + $nestedDictionaryProperty->setRemoveButton($this->getElement('remove_' . $i)); + $this->addElement($nestedDictionaryProperty); + } + + if ($newCount === 0) { + if (empty($this->nestedItems)) { + $this->addHtml(new EmptyStateBar(Html::sprintf( + $this->translate( + 'No fields configured for this dictionary.' + . ' Add fields to the custom variable definition in %s.' + ), + new Link( + $this->translate('Custom Variables'), + Url::fromPath('director/customvar', ['uuid' => $this->uuid->toString()]), + ['data-base-target' => '_next'] + ) + ))); + } else { + $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) + { + if (! isset($values['count'])) { + $values['count'] = count($values); + } + + return parent::populate($values); + } + + /** + * Get the nested dictionary value + * + * @param bool $applyUnchangedDefaults See DictionaryItem::getItem() + * + * @return array + */ + public function getDictionary(bool $applyUnchangedDefaults = true): array + { + $values = []; + $count = 0; + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof NestedDictionaryItem) { + $property = $element->getItem($applyUnchangedDefaults); + 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..02e3789b1 --- /dev/null +++ b/application/forms/DictionaryElements/NestedDictionaryItem.php @@ -0,0 +1,142 @@ + ['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 + ]); + + $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'])) + ->setIsChild() + ); + } + + /** + * 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']]) + && ! CustomVariablesForm::isValueUnset($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 + * + * @param bool $applyUnchangedDefaults See DictionaryItem::getItem() + * + * @return NestedDictionaryItemDataType + */ + public function getItem(bool $applyUnchangedDefaults = true): array + { + $this->ensureAssembled(); + $key = $this->getElement('key')->getValue(); + $values = []; + $values['key'] = $key; + $values['value'] = $this->getElement('var')->getDictionary($applyUnchangedDefaults); + + return $values; + } +} diff --git a/application/forms/HostServiceBlacklistForm.php b/application/forms/HostServiceBlacklistForm.php new file mode 100644 index 000000000..fd9e3f13b --- /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->addCsrfCounterMeasure(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/IcingaServiceForm.php b/application/forms/IcingaServiceForm.php index b97aa9a94..a86731944 100644 --- a/application/forms/IcingaServiceForm.php +++ b/application/forms/IcingaServiceForm.php @@ -7,7 +7,7 @@ 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\DataType\DataTypeArray; use Icinga\Module\Director\Exception\NestingError; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Web\Form\DirectorObjectForm; @@ -17,7 +17,6 @@ use Icinga\Module\Director\Web\Table\ObjectsTableHost; use ipl\Html\Html; use gipfl\IcingaWeb2\Link; -use ipl\Html\HtmlElement; use RuntimeException; class IcingaServiceForm extends DirectorObjectForm @@ -41,9 +40,12 @@ class IcingaServiceForm extends DirectorObjectForm /** @var bool|null */ private $blacklisted; + /** @var ?IcingaHost */ private $blacklistedAncestor; + private $dictionaryUuidMap = []; + public function setApplyGenerated(IcingaService $applyGenerated) { $this->applyGenerated = $applyGenerated; @@ -672,6 +674,59 @@ protected function addHostObjectElement() return $this; } + /** + * Fetch the custom variables that can be used for apply for rule + * + * @return array + */ + 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->label . ' (' . $var->key_name . ')' + : $var->key_name; + if ($var->value_type === 'dynamic-dictionary') { + $this->dictionaryUuidMap['host.vars.' . $var->key_name] = $var->uuid; + } + } + + $datafieldQuery = $this->db->getDbAdapter() + ->select() + ->distinct() + ->from( + ['df' => 'director_datafield'], + ['varname' => 'df.varname', 'caption' => 'df.caption'] + ) + ->join(['ihf' => 'icinga_host_field'], 'df.id = ihf.datafield_id', []) + ->where("df.datatype = ?", DataTypeArray::class); + + foreach ($this->db->getDbAdapter()->fetchAll($datafieldQuery) as $df) { + $key = 'host.vars.' . $df->varname; + if (! array_key_exists($key, $properties)) { + $properties[$key] = $df->caption . ' (' . $df->varname . ')'; + } + } + + return [t('director', 'Custom variables') => $properties]; + } + /** * @return $this * @throws \Zend_Form_Exception @@ -679,24 +734,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..8b3cdfe7b --- /dev/null +++ b/application/forms/ObjectCustomvarForm.php @@ -0,0 +1,131 @@ +customVars = $this->getCustomVars(); + } + + public function getPropertyName(): string + { + $propertyUuid = $this->getValue('property'); + if ($propertyUuid) { + return $this->customVars[$propertyUuid] ?? ''; + } + + return ''; + } + + /** + * Whether the "Required" checkbox was ticked + * + * @return bool + */ + public function isRequired(): bool + { + return $this->getValue('required') === 'y'; + } + + protected function assemble(): void + { + $this->addCsrfCounterMeasure(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('checkbox', 'required', [ + 'label' => $this->translate('Required'), + 'description' => $this->translate( + 'Whether objects using this template must provide a value for this variable' + ) + ]); + + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Add') + ]); + } + + /** + * Get custom variables linked to the object + * + * @return array + */ + 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'); + + $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) + ); + + $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 = []; + foreach ($properties as $property) { + $uuid = DbUtil::binaryResult($property->uuid); + $uuidStr = Uuid::fromBytes($uuid)->toString(); + if (! in_array($uuidStr, $this->alreadyAddedUuids, true)) { + $propUuidKeyPairs[$uuidStr] = $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..f56133b4b --- /dev/null +++ b/application/forms/Validator/DatalistEntryValidator.php @@ -0,0 +1,62 @@ +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..65ff6fc1b 100644 --- a/configuration.php +++ b/configuration.php @@ -175,3 +175,16 @@ ->setUrl('director/config/deployments') ->setPriority(902) ->setPermission(Permission::DEPLOYMENTS); +$section->add(N_('Custom Variables')) + ->setUrl('director/variables') + ->setPermission(Permission::ADMIN) + ->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 b/doc/02-Installation.md index 10604d11b..ed237ed3c 100644 --- a/doc/02-Installation.md +++ b/doc/02-Installation.md @@ -59,7 +59,8 @@ mysql -e "CREATE DATABASE director CHARACTER SET 'utf8'; psql -q -c "CREATE DATABASE director WITH ENCODING 'UTF8';" psql director -q -c "CREATE USER director WITH PASSWORD 'CHANGEME'; GRANT ALL PRIVILEGES ON DATABASE director TO director; -CREATE EXTENSION pgcrypto;" +CREATE EXTENSION pgcrypto; +CREATE EXTENSION citext;" ``` ## Configuring Icinga Director diff --git a/doc/12-Handling-custom-variables.md b/doc/12-Handling-custom-variables.md index 3c9a9cf4e..83add2bb5 100644 --- a/doc/12-Handling-custom-variables.md +++ b/doc/12-Handling-custom-variables.md @@ -1,13 +1,397 @@ Working with custom variables =================================================================== -Icinga Director allows you to work with custom variables in a very -powerful way. It implements the concept of `Data fields`. If you want -your users to be able to fill specific custom variables, you need to -add corresponding `fields` to +Custom variables are extra bits of information you attach to a host, +service or other object, things like a URL to check, a warning +threshold, or login details for a plugin. Icinga Director gives you two +ways to let your users fill these in: + +* the original `Data fields` concept, tied to a specific object and now + **deprecated** +* the newer `Custom Variables` concept, which can also hold lists and + grouped values, works the same way on every object type, and is what + you should use when setting things up today + +Custom variables with deprecated Data fields +-------------------------------------------- + +Icinga Director lets you work with custom variables through the +concept of `Data fields`. If you want your users to fill in specific +custom variables, add the corresponding `fields` to your Host, +Service, Command, User or Notification template. + +On any object or template, the tab that lets you assign `Data fields` is +now labelled `Fields (Deprecated)`. Existing configuration continues to +work, but new custom variables should be created using the `Custom +Variables` concept described below. See +[Migrating existing Data fields](#Migrating-existing-Data-fields) if you +already have `Data fields` in place. Examples -------- * Add fields for existing commands * Allow to fill an [array of interfaces](14-Fields-example-interfaces-array.md) +Custom Variables +----------------- + +The newer Custom variables support is the recommended way to add custom data to +your objects, replacing `Data fields`. Compared to `Data fields`, they can also hold lists and +grouped values instead of just plain text or numbers, they work the +same way on hosts, services, commands, users and notifications, and +they are understood by configuration baskets, the REST API and `Apply +For` rules. + +### Custom Variable Types + +A new `Custom Variables` menu entry is available under the Icinga +Director main menu (`director/variables`). Custom variables are +configured independently of `Data fields` and support the following +types: + +| Type | UI label | Description | +|-----------------------|------------------------|-----------------------------------------------------------------------------------------------------| +| `string` | String | Plain text value | +| `number` | Number | Numeric value | +| `bool` | Boolean | True/false value | +| `sensitive` | Sensitive | Plain text value that is masked in the value input and in read views, such as a password or token | +| `fixed-array` | Fixed Array | Ordered list with a pre-defined structure; values assigned to preconfigured positions | +| `datalist-strict` | Data List Strict | Only values from the chosen datalist can be assigned; can be stored as a single value or an array | +| `datalist-non-strict` | Data List Non Strict | Values outside the chosen datalist are also accepted; can be stored as a single value or an array | +| `dynamic-array` | Dynamic Array | Uniform array where end-users can add values freely | +| `fixed-dictionary` | Fixed Dictionary | Key-value map with a fixed set of preconfigured keys | +| `dynamic-dictionary` | 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. The fields of a `fixed-array`, +> `fixed-dictionary` or `dynamic-dictionary` may only be scalar (`string`, +> `number`, `bool`, `sensitive`), datalist (`datalist-strict`, +> `datalist-non-strict`), or `dynamic-array` types. A nested field can +> never itself be a `fixed-array`, `fixed-dictionary` or +> `dynamic-dictionary`, that also rules out nesting a `fixed-array` +> inside another `fixed-array`. A `dynamic-array` can be nested this +> way, but not inside another `dynamic-array`. Also, `dynamic-dictionary` +> can only be defined as a top-level property; it cannot be nested +> inside another array or dictionary. +> +> `sensitive` is not offered as the item type of a `dynamic-array` or a +> datalist. Both render their values as a plain visible list, and there +> is no way to mask individual entries in that kind of list. +> +> For `fixed-array`, all positions must be supplied on the object. None +> may be omitted. + +#### Examples for each type + +##### `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 +``` + +##### `bool` +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 +``` + +##### `sensitive` +A plain text value that gets masked wherever it's shown to a user, both in the value +input and in read views. It's stored as plain text, the same way a plain +`visibility = hidden` field was under the old `Data fields` concept, so it's not meant +as a replacement for a secrets manager, just a way to keep a value off the screen. + +``` +# On a host, the SNMP community string used to poll this device +vars.snmp_community = "s3cr3t-community" + +# On a command, an API token needed to reach a paging service +vars.pagerduty_token = "u+abc123def456" +``` + +A `sensitive` field nested inside a `fixed-array` or `fixed-dictionary` is masked the +same way. Masking is scoped to that field's exact position in its own property +definition, so two unrelated properties can each have a nested field with the same +name (e.g. both defining a `credentials.password`) without one accidentally unmasking +the other. + +##### `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 datalist. 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 datalist 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" +} +``` + +A `fixed-dictionary` field can itself be a `dynamic-array`: + +``` +# On a host: MySQL connection parameters, with an array of fallback hosts +vars.mysql = { + host = "db-primary.internal" + fallback_host = ["db-replica-1.internal", "db-replica-2.internal"] + port = "3306" +} +``` + +##### `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"] + } +} +``` + +### Configuring a custom variable + +Go to `Custom Variables` in the Icinga Director menu and choose +`Add Custom Variable`. The form lets you configure: + +* `Property Key`: the variable name (e.g. `disk_checks`), used as + `vars.` in the rendered config +* `Property Label` and `Property Description`: optional, shown in + object forms and the apply-for hint text +* `Category`: optional, groups related properties in object forms, + same as with `Data fields` +* `Property Type`: one of the types listed above +* `List name`: only for `datalist-strict` / `datalist-non-strict`, + selects which Director datalist supplies the allowed values +* `Item Type`: for `dynamic-array` and for the array variant of a + datalist type, selects whether items are scalar values or, for + datalists, a `dynamic-array` of values + +Once a `fixed-array`, `fixed-dictionary` or `dynamic-array` / +`dynamic-dictionary` property has been created, use its detail page to +add the nested items: fixed positions for `fixed-array`, fixed keys for +`fixed-dictionary`, or the single item type for `dynamic-array` / +`dynamic-dictionary`. + +> Once a property is used on one or more templates, its `Property Type`, +> `Item Type` and `List name` can no longer be changed. Remove it from +> all templates first if it needs to change. + +### Attaching custom variables to objects and templates + +Every object type that supports custom variables (host, service, command, +user and notification) exposes a `Custom Variables` tab on its object and +template detail pages, next to the `Fields (Deprecated)` tab. Service sets +do not expose this tab yet. Use `Add Custom Variable` there to attach a +configured property and fill in its value: + +* Custom variables inherited from imported templates are shown and can + be overridden on the object itself. +* `dynamic-dictionary` values are **merged** across the inheritance + chain rather than overwritten. The rendered config uses `+=`, so a + child template or the object itself can add further entries without + losing the ones defined on parent templates. + +### Marking a custom variable as required + +Besides attaching a property and giving it a value, an attachment can be +marked `Required`. Toggle this on the `Custom Variables` tab of the +**template** where the variable was directly attached; there's no need to +remove and re-add the variable just to change its requiredness. + +The flag never blocks saving the template itself, templates describe the +schema, they don't have to fill it in. It takes effect once the variable +reaches an actual host, service, command, user or notification object, +whether attached there directly or inherited. + +* Saving that object's `Custom Variables` tab fails if a required + variable has no value, whether the value comes from the object itself + or is inherited from one of its imported templates. +* An inherited value already satisfies the check, you don't need to + repeat it on the object. +* For `fixed-array` and `fixed-dictionary` properties, an attachment + whose children are all empty/default (e.g. a blank fixed array) is + treated as having no value, so the required check still triggers + instead of silently passing. + +Apply For rules +--------------- + +`dynamic-array` and `dynamic-dictionary` custom variables defined on a +**host template** can be used as the source of a service `Apply For` +rule, letting Director create one service per array entry or dictionary +key. See [Working with Apply For rules](15-Service-apply-for-example.md) +for a full walk-through, including the `$value$` / `$key$` syntax used to +reference the iterated value inside the apply rule. + +Migrating existing Data fields +------------------------------------------------------------------------- + +Existing `Data fields` can be converted to custom variables with: + +```bash +icingacli director migrate datafields --dry-run --verbose +icingacli director migrate datafields --verbose +icingacli director migrate datafields --verbose --delete # also removes the migrated fields +``` + +Only fields matching **all** of the following are migrated; everything +else is skipped and reported: + +* data type is one of `String`, `Number`, `Boolean`, `Array`, `Datalist` +* the field has no category +* there is no other field sharing the same variable name +* no custom variable with the same key already exists + +| Data field type | Custom variable type | +|------------------|-----------------------| +| `DataTypeString` | `string`, or `sensitive` if the field's visibility was set to `hidden` | +| `DataTypeNumber` | `number` | +| `DataTypeBoolean` | `bool` | +| `DataTypeArray` | `dynamic-array` (string items) | +| `DataTypeDatalist` (strict / suggest strict) | `datalist-strict` | +| `DataTypeDatalist` (other) | `datalist-non-strict` | + +Existing template assignments are carried over automatically, so +migrated variables show up already attached to the same host, service, +command, user and notification templates that used the original field. + +> If a property created by a migration is later renamed or removed, +> values that were already migrated under it are not automatically +> relinked to the change. Treat a freshly migrated property's key name +> and lifetime with a bit of extra care until this is automated. + +Configuration Baskets +--------------------- + +Configuration baskets capture custom variable definitions (and their +nested items) together with the templates that use them, so restoring a +basket snapshot restores both the template and the custom variable +schema it depends on. + +> For a `datalist-strict` or `datalist-non-strict` property, only the +> datalist's name travels with the basket, not its entries. Restoring +> such a snapshot onto a target that doesn't already have a matching +> datalist creates it empty; populate the datalist there separately. + +Custom variable values on an existing object can also be updated +directly through the REST API, without having to submit the whole +object. See [Custom Variables](70-REST-API.md#Custom-Variables) in the +REST API documentation for details and examples. diff --git a/doc/15-Service-apply-for-example.md b/doc/15-Service-apply-for-example.md index 531e4a737..b6daa77aa 100644 --- a/doc/15-Service-apply-for-example.md +++ b/doc/15-Service-apply-for-example.md @@ -1,12 +1,19 @@ Working with Apply for rules - tcp ports example ============================================== -This example wants to show you how to make use of `Apply For` rule for services. +This example walks you through using an `Apply For` rule to spin up services +automatically, using open TCP ports as the example. -First you need to define a `tcp_ports` data field of type `Array` assigned to a `Host Template`. -Refer to [Working with fields](14-Fields-example-interfaces-array.md) section to setup a data field. -You also need to define a `tcp_port` data field of type `String`, we will associate it to a -`Service Template` later. +> `Apply For` also works with the newer [Custom Variables](12-Handling-custom-variables.md) +> of type `dynamic-array` and `dynamic-dictionary`, which is the recommended way to go +> since it also supports iterating over dictionaries. See +> [Apply For with Custom Variables](#Apply-For-with-Custom-Variables) below. The +> walk-through that follows still uses the deprecated `Data fields`. + +First, define a `tcp_ports` data field of type `Array` and assign it to a `Host Template`. +See [Working with fields](14-Fields-example-interfaces-array.md) if you need a refresher on +setting up a data field. You'll also need a `tcp_port` data field of type `String`, which we'll +associate with a `Service Template` later. Then, please go to the `Dashboard` and choose the `Monitored services` dashlet: @@ -24,21 +31,129 @@ Then create a new `apply-rule` for the `Service template`: ![Define apply rule](screenshot/director/15_apply-for-services/154_create_apply_rule.png) -Now define the `Apply For` property, select the previously defined field `tcp_ports` associated to -the host template. `Apply For` rule define a variable `config` that can be used as `$config$`, it -corresponds to the item of the array it will iterate on. +Now set the `Apply For` property to the `tcp_ports` field you defined earlier on the host template. +An `Apply For` rule exposes a variable named `value`, usable as `$value$`, which corresponds to +whichever array item is currently being iterated over. -Set the `Tcp port` property to `$config$`: +Set the `Tcp port` property to `$value$`: ![Add field to template](screenshot/director/15_apply-for-services/155_configure_apply_for.png) (Side note: if you can't see your `tcp_ports` property in `Apply For` dropdown, try to create one host with a non-empty `tcp_ports` value.) -That's it, now all your hosts defining a `tcp_ports` variable will be assigned the `Tcp Check` -service. +That's it. Every host that defines a `tcp_ports` variable will now get the `Tcp Check` service +assigned automatically. -Have a look at the config preview, it will show you how `Apply For` services will look like once -deployed: +Take a look at the config preview to see how the `Apply For` services will render once deployed: ![Host config preview with Array](screenshot/director/15_apply-for-services/156_config_preview.png) + + +Apply For with Custom Variables +-------------------------------- + +The [Custom Variables](12-Handling-custom-variables.md) system offers the same `Apply For` +mechanism, but isn't limited to flat arrays. A `dynamic-array` custom variable behaves just like +the `tcp_ports` example above, while a `dynamic-dictionary` custom variable also gives you the +dictionary key of each iterated entry, plus direct access to its sub-dictionary fields. + +Only `dynamic-array` and `dynamic-dictionary` custom variables that are attached to a **Host +Template** show up in the `Apply For` dropdown of a service apply rule. + +### Example: Apply For over a `dynamic-array` + +**Scenario:** a host has a `dynamic-array` custom variable `http_vhosts_list` listing virtual host +addresses to probe. A service apply rule creates one HTTP check per entry. + +1. Under `Custom Variables`, create a property `http_vhosts_list` of type `Dynamic Array` with item + type `String`. +2. On the `Host Template`, open the `Custom Variables` tab and add `http_vhosts_list`. +3. On a host importing that template, fill in the values: + + ``` + vars.http_vhosts_list = [ + "www.example.com", + "api.example.com", + "status.example.com" + ] + ``` + +4. Create a `Service Template` with `check_command = http`, and its own custom variable + `http_address` (type `String`). +5. Create an `Apply Rule` for that template, set `Apply For` to `http_vhosts_list`, the service + name pattern to `http - $value$`, and `http_address` to `$value$`. + +The rendered configuration looks like: + +``` +apply Service "http - " for (value in host.vars.http_vhosts_list) { + check_command = "http" + vars.http_address = value + + assign where host.vars.http_vhosts_list +} +``` + +Three services are created on that host: `http - www.example.com`, `http - api.example.com` and +`http - status.example.com`. + +### Example: Apply For over a `dynamic-dictionary` + +**Scenario:** a host has a `dynamic-dictionary` custom 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. + +1. Under `Custom Variables`, create a property `disk_checks` of type `Dynamic Dictionary`. On its + detail page, add the sub-dictionary fields `disk_partition`, `disk_wfree` and `disk_cfree` + (type `String`). +2. Attach `disk_checks` to the `Host Template`'s `Custom Variables` tab. +3. On the host (or, thanks to dictionary merging, spread across several imported templates), fill + in the entries: + + ``` + vars.disk_checks += { + "root" = { + disk_partition = "/" + disk_wfree = "20%" + disk_cfree = "10%" + } + "data" = { + disk_partition = "/data" + disk_wfree = "15%" + disk_cfree = "5%" + } + } + ``` + +4. Create a `Service Template` with `check_command = disk` and custom variables `disk_partitions`, + `disk_wfree`, `disk_cfree` (type `String`). +5. Create an `Apply Rule`, set `Apply For` to `disk_checks`, the service name pattern to + `disk - $key$`, and the custom variables to: + + | Variable | Value | + |----------------------|---------------------------| + | `disk_partitions` | `$value.disk_partition$` | + | `disk_wfree` | `$value.disk_wfree$` | + | `disk_cfree` | `$value.disk_cfree$` | + + The `Apply For` page shows a hint listing all `$value.$` (or `$value["field-name"]$` for + field names that aren't valid identifiers) expressions available for the selected dictionary. + +The rendered configuration looks like: + +``` +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 +} +``` + +Two services are created: `disk - root` (checking `/`) and `disk - data` (checking `/data`). +Because `disk_checks` is merged with `+=` across the inheritance chain, further disk entries can be +added on a child template or on the host itself without losing entries defined further up the +template tree. diff --git a/doc/30-Configuration-Baskets.md b/doc/30-Configuration-Baskets.md index f077a74de..684b6c20c 100644 --- a/doc/30-Configuration-Baskets.md +++ b/doc/30-Configuration-Baskets.md @@ -90,3 +90,16 @@ selections). For each item in that list, the keywords *unchanged* or *new* will appear to the right. Clicking on *new* will show the differences between the version in the snapshot and the current configuration. + + + +### Version Compatibility + +Snapshots created on a Director version that supports new [Custom Variables](12-Handling-custom-variables.md) (>= 1.12.0) +include a `CustomVariable` element type. Restoring such a snapshot on an older Director +version (< 1.12.0) that predates this feature will silently drop the `CustomVariable` data, since older +versions do not know how to interpret it. This is relevant when using baskets to share or sync +configuration between Director instances that are not on the same version, for example between +a master and a satellite/config master. To avoid losing custom variable data, keep Director +versions aligned across instances that exchange baskets, or upgrade the receiving instance +before restoring a snapshot from a newer one. diff --git a/doc/70-REST-API.md b/doc/70-REST-API.md index 6e7770da6..f023295f2 100644 --- a/doc/70-REST-API.md +++ b/doc/70-REST-API.md @@ -230,6 +230,245 @@ director/host?name=pe2015.example.com&resolved JSON is pretty-printed per default, at least for PHP >= 5.4 +Custom Variables +--------------------------------------------- + +Custom variable values on an existing object can also be updated +directly, without having to submit the whole object, using a dedicated +endpoint. + + POST director//variables? + PUT director//variables? + +with a JSON body whose keys are variable names and values are the new +values. Strings, numbers, booleans, arrays and nested dictionaries are +all accepted, matching the types described in +[Working with custom variables](12-Handling-custom-variables.md). The +custom variable must already be configured under `Custom Variables` +**and** already be attached to (or inherited from) the object you are +updating. What happens when a variable isn't attached yet depends on the +method and the object type. See [Attaching a variable to a template for +the first time](#Custom-Variables-attach-template) and +[Variable not configured](#Custom-Variables-not-configured) below. + +As with the general `POST`/`PUT` semantics described above, the two +methods differ in how they treat variables you don't mention in the +body. + +* `POST` **merges**: only the keys you send are touched, all other + existing variables on the object are left untouched. +* `PUT` **replaces**: All the custom variables in the object are replaced with + the ones in the body. Values inherited from templates are not affected either + way, since they aren't stored on the object itself. + +`PUT` replaces variables by detaching and reattaching them behind the scenes. +The `required` flag configured for an attachment (see +[Marking a custom variable as required](12-Handling-custom-variables.md#Required-custom-variables)) +is preserved across that relink; this endpoint only reads and writes values, +there's no way to set or query the `required` flag through it. Changing it +still requires the `Custom Variables` tab of the template it was attached on. + +#### Setting a `string` variable + + POST director/host/variables?name=apitest + +```json +{ "environment": "production" } +``` + +#### Setting a `number` variable + + POST director/service/variables?name=Uptime&host=apitest + +```json +{ "snmp_timeout": 30 } +``` + +#### Setting a `bool` variable + + POST director/host/variables?name=apitest + +```json +{ "ssl_verify": true } +``` + +#### Setting a `sensitive` variable + + POST director/host/variables?name=apitest + +```json +{ "snmp_community": "s3cr3t-community" } +``` + +A `sensitive` variable is masked in the web UI and in the Icinga DB read view, but that +masking doesn't extend to the API. A `GET` on this object returns `snmp_community` with +its real value, not `***`, since the API is meant to give you back exactly what's +stored. Keep that in mind when logging requests or sharing API responses. + +#### Setting an array variable (`fixed-array`, `dynamic-array`, or a datalist array) + + POST director/host/variables?name=apitest + +```json +{ "ssh_args": ["monitoring", "22", "/etc/icinga2/ssh/id_rsa"] } +``` + +#### Setting a dictionary variable (`fixed-dictionary`) + + POST director/host/variables?name=apitest + +```json +{ + "mysql": { + "host": "db-primary.internal", + "port": "3306", + "user": "icinga_monitor", + "password": "s3cr3t", + "database": "app_production" + } +} +``` + +Only the outer shape is currently checked here, the value must be a JSON +object. The individual keys inside it are not yet validated against the +property's configured keys and per-key types, so a request using the wrong +keys can still be accepted. + +#### Setting several variables at once + + POST director/host/variables?name=apitest + +```json +{ + "environment": "production", + "owner_teams": ["networking", "platform"] +} +``` + +##### Response + +Just like `GET` on the object's base endpoint, the response is the +**full object**, not just the variables you sent; `vars` contains every +custom variable currently set on `apitest`, including ones this call +didn't touch. + +``` +HTTP/1.1 200 OK +Content-Type: application/json +``` + +```json +{ + "object_name": "apitest", + "object_type": "object", + "address": "127.0.0.1", + "vars": { + "environment": "production", + "owner_teams": [ + "networking", + "platform" + ] + } +} +``` + +#### Replacing all variables set on an object + +Unlike the examples above, `PUT` drops every custom variable +previously set directly on `apitest` before applying the body, so only +`environment` survives this call even if other variables had been set +before: + + PUT director/host/variables?name=apitest + +```json +{ "environment": "production" } +``` + +#### Attaching a variable to a template for the first time +------------------------------------------------------------------------------------------------------- + +Both `POST` and `PUT` can update the value of a variable that is +already attached to the object (or inherited from one of its imported +templates). Only `PUT`, and only when the target object is itself a +**template**, can additionally attach a variable that is configured +under `Custom Variables` but not yet used on that template; Director +creates the attachment and then stores the value in the same call. + + PUT director/host/variables?name=generic-host + +```json +{ "datacenter": "eu-west-1" } +``` + +Sending the very same body with `POST` instead fails, because `POST` is +not allowed to change which custom variables are attached to a +template. + +``` +HTTP/1.1 404 Not Found +Content-Type: application/json +``` + +```json +{ + "error": "The custom variable datacenter should be first added to the template" +} +``` + +The same restriction applies to non-template objects (hosts, services, +...) regardless of method. A variable must first be attached to one of +the object's imported templates before it can be set on the object +itself. + +``` +HTTP/1.1 404 Not Found +Content-Type: application/json +``` + +```json +{ + "error": "The custom variable datacenter should be first added to one of the imported templates for this object" +} +``` + +#### Variable not configured + +The message depends on how the variable is unknown. + +* On `POST`, or on `PUT` to a non-template object, a variable that isn't + yet attached to the object gets the same "should be first added..." + error shown above, whether or not it has been configured under `Custom + Variables` elsewhere. +* Only `PUT` to a **template** distinguishes the case where the variable + has never been configured under `Custom Variables` at all, meaning + there is no matching top-level entry in `Custom Variables` to attach. + +``` +HTTP/1.1 404 Not Found +Content-Type: application/json +``` + +```json +{ + "error": "'unknown_var' is not configured in Icinga Director as a custom variable" +} +``` + +#### GET + +`GET` is also accepted on this endpoint. + + GET director/host/variables?name=apitest + +It behaves exactly like `GET` on the object's base endpoint +(`director/host?name=apitest`) and returns the full object, not just +its custom variables; there's no dedicated "variables only" response. +Use the `properties` parameter if you only want the `vars` property +back. + + GET director/host/variables?name=apitest&properties=vars + Error handling -------------- diff --git a/library/Director/CustomVariable/CustomVariable.php b/library/Director/CustomVariable/CustomVariable.php index 4b5dd3e43..dca8d0887 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 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..de061f540 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,9 +67,11 @@ public function setValue($value) public function getValue() { $ret = (object) array(); - ksort($this->value); - foreach ($this->value as $key => $var) { + $sortedValue = $this->value; + ksort($sortedValue); + + foreach ($sortedValue as $key => $var) { $ret->$key = $var->getValue(); } @@ -119,6 +122,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/CustomVariableValueCleaner.php b/library/Director/CustomVariable/CustomVariableValueCleaner.php new file mode 100644 index 000000000..0579fcb57 --- /dev/null +++ b/library/Director/CustomVariable/CustomVariableValueCleaner.php @@ -0,0 +1,376 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Director\CustomVariable; + +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Data\Db\DbConnection; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Db\DbUtil; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use Zend_Db; + +/** + * Keeps stored custom variable values in sync when a property's schema loses a nested key. + */ +class CustomVariableValueCleaner +{ + public function __construct(protected DbConnection $db) + { + } + + /** + * Fetch property for the given UUID + * + * @return array + */ + public 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 = ?', DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + return DbUtil::normalizeRow($db->fetchRow($query, [], Zend_Db::FETCH_ASSOC) ?: []); + } + + /** + * Walk parent_uuid up from $parent to the root, collecting key_names along the way. + * + * @return array{0: array, 1: string[]} + */ + public function resolveRootProperty(array $property, array $parent): array + { + $path = [$property['key_name']]; + $current = $parent; + + while ($current['parent_uuid'] !== null) { + array_unshift($path, $current['key_name']); + $current = $this->fetchProperty(Uuid::fromBytes($current['parent_uuid'])); + } + + return [$current, $path]; + } + + /** + * Remove dictionary item from the given data array + * + * @param bool $preserveIndex null out the terminal key instead of unsetting it, so a + * fixed-array's sibling positions don't shift + * + * @return void + */ + private function removeDictionaryItem(array &$item, array $path, bool $preserveIndex = false): void + { + $key = array_shift($path) ?? ''; + + if (! array_key_exists($key, $item)) { + return; + } + + if (empty($path)) { + if ($preserveIndex) { + $item[$key] = null; + } else { + unset($item[$key]); + } + } elseif (isset($item[$key]) && is_array($item[$key])) { + $this->removeDictionaryItem($item[$key], $path, $preserveIndex); + } + + if (! $preserveIndex && isset($item[$key]) && is_array($item[$key]) && empty($item[$key])) { + unset($item[$key]); + } + } + + /** + * Strip $path out of every entry in a dynamic dictionary, in place. + * + * @return void + */ + private function removeDictionaryItemFromEveryEntry( + array &$dynamicDictionaryValue, + array $path, + bool $preserveIndex = false + ): void { + foreach ($dynamicDictionaryValue as $entryKey => $entryValue) { + if (! is_array($entryValue)) { + continue; + } + + $this->removeDictionaryItem($dynamicDictionaryValue[$entryKey], $path, $preserveIndex); + } + } + + /** + * Strip $property's value out of every host's, service's, etc. stored custom variables. + * No-op for a root property (empty $parent), use deleteStoredValues() for that case. + * + * @param bool $keepPropertyInPlace true when $property is only being retyped, not removed + * + * @return void + */ + public function removeObjectCustomVars( + array $property, + ?array $parent = null, + bool $keepPropertyInPlace = false + ): void { + if (empty($parent)) { + return; + } + + $db = $this->db->getDbAdapter(); + $parentUuid = Uuid::fromBytes($parent['uuid']); + + [$rootProp, $path] = $this->resolveRootProperty($property, $parent); + $rootUuid = Uuid::fromBytes($rootProp['uuid']); + $rootType = $rootProp['value_type']; + + $isParentFixedArray = $parent['value_type'] === 'fixed-array'; + $isRootFixedArray = $rootType === 'fixed-array'; + $preserveIndex = $keepPropertyInPlace && $isParentFixedArray; + + // A retyped-in-place property keeps its slot, only a removed one needs its + // fixed-array siblings renumbered. + if (! $keepPropertyInPlace && $isParentFixedArray) { + $this->updateFixedArrayItems($parentUuid, $property['key_name']); + } + + foreach (['host', 'service', 'notification', 'command', 'user'] as $objectType) { + $idColumn = "{$objectType}_id"; + // Match by varname, not property_uuid, root key_names are unique and property_uuid + // is only ever an optional hint that isn't reliably populated on every stored row. + $varRows = $db->fetchAll( + $db->select() + ->from(['iov' => "icinga_{$objectType}_var"], []) + ->columns([$idColumn, 'varname', 'varvalue']) + ->where('varname = ?', $rootProp['key_name']), + [], + 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, $preserveIndex); + } else { + $this->removeDictionaryItemFromEveryEntry($varValue, $path, $preserveIndex); + foreach ($varValue as $entryKey => $entryValue) { + if ($entryValue === []) { + $varValue[$entryKey] = (object) []; + } + } + } + + $object = $objectClass::loadWithAutoIncId($varRow[$idColumn], $this->db); + $vars = $object->vars(); + + if (empty($varValue)) { + $vars->set($varRow['varname'], null); + $vars->storeToDb($object); + + continue; + } + + if ( + ! $preserveIndex + && ($isRootFixedArray || ($isParentFixedArray && $rootUuid->equals($parentUuid))) + ) { + $varValue = array_values($varValue); + } + + $vars->set($varRow['varname'], $varValue); + $vars->storeToDb($object); + } + } + } + + /** + * Strip $property's value out of every host's _override_servicevars custom variable. + * + * @param bool $keepPropertyInPlace see removeObjectCustomVars() + * + * @return void + */ + public function removeFromOverrideServiceVars( + array $property, + array $parent, + bool $keepPropertyInPlace = false + ): void { + $db = $this->db->getDbAdapter(); + + $overrideVarname = $db->fetchOne( + $db->select() + ->from('director_setting', ['setting_value']) + ->where('setting_name = ?', 'override_services_varname') + ) ?: '_override_servicevars'; + + if (empty($parent)) { + $rootKeyName = $property['key_name']; + $rootType = $property['value_type']; + $pathWithinRootValue = null; + $preserveIndex = false; + } else { + [$rootProp, $pathWithinRootValue] = $this->resolveRootProperty($property, $parent); + $rootKeyName = $rootProp['key_name']; + $rootType = $rootProp['value_type']; + $preserveIndex = $keepPropertyInPlace && $parent['value_type'] === 'fixed-array'; + } + + $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) { + unset($serviceVars[$rootKeyName]); + } elseif ($rootType === 'dynamic-dictionary') { + if (is_array($serviceVars[$rootKeyName])) { + $this->removeDictionaryItemFromEveryEntry( + $serviceVars[$rootKeyName], + $pathWithinRootValue, + $preserveIndex + ); + if (! $preserveIndex) { + foreach ($serviceVars[$rootKeyName] as $entryKey => $entryValue) { + if ($entryValue === []) { + unset($serviceVars[$rootKeyName][$entryKey]); + } + } + } + } + + if (! $preserveIndex && empty($serviceVars[$rootKeyName])) { + unset($serviceVars[$rootKeyName]); + } + } else { + $this->removeDictionaryItem($serviceVars[$rootKeyName], $pathWithinRootValue, $preserveIndex); + if (! $preserveIndex && 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, + ] + ); + } + } + } + + /** + * Drop every stored value for a root property outright, across every object type. + * + * @param string $varname the root property's own key_name. Only call this for a root + * property, a nested property's key_name isn't guaranteed unique + * and could collide with an unrelated root variable + * + * @return void + */ + public function deleteStoredValues(string $varname): void + { + foreach (['host', 'service', 'notification', 'command', 'user', 'service_set'] as $object) { + $this->db->delete("icinga_{$object}_var", Filter::where('varname', $varname)); + } + } + + /** + * Update the items for the given fixed array + * + * @return void + */ + private function updateFixedArrayItems(UuidInterface $uuid, string $propertyIndex): void + { + $db = $this->db->getDbAdapter(); + $quotedUuid = DbUtil::quoteBinaryCompat($uuid->getBytes(), $db); + + // Delete first so a renumbered survivor can't collide with this key_name. + $db->delete( + 'director_property', + [ + 'parent_uuid = ?' => $quotedUuid, + 'key_name = ?' => $propertyIndex, + ] + ); + + $propItems = array_map( + [DbUtil::class, 'normalizeRow'], + $db->fetchAll( + $db->select() + ->from('director_property', ['uuid', 'key_name']) + ->where('parent_uuid = ?', $quotedUuid), + [], + Zend_Db::FETCH_ASSOC + ) + ); + // Sort numerically, not lexicographically ('10' would otherwise sort before '2'). + usort($propItems, fn($a, $b) => (int) $a['key_name'] <=> (int) $b['key_name']); + + foreach ($propItems as $index => $propItem) { + if ($propItem['key_name'] === (string) $index) { + continue; + } + + $db->update( + 'director_property', + ['key_name' => $index], + $db->quoteInto( + 'uuid = ?', + DbUtil::quoteBinaryCompat($propItem['uuid'], $db) + ) + ); + } + } +} diff --git a/library/Director/CustomVariable/CustomVariableValueValidator.php b/library/Director/CustomVariable/CustomVariableValueValidator.php new file mode 100644 index 000000000..0f498dabd --- /dev/null +++ b/library/Director/CustomVariable/CustomVariableValueValidator.php @@ -0,0 +1,124 @@ +getDatalist(); + if ($datalist === null) { + return; + } + + $allowedNames = []; + foreach ($datalist->getEntries() as $entry) { + $allowedNames[] = $entry->get('entry_name'); + } + + foreach ((array) $value as $singleValue) { + if (! in_array($singleValue, $allowedNames, true)) { + throw new InvalidArgumentException(sprintf( + "'%s' is not a valid value for the custom variable '%s'", + (string) $singleValue, + $key + )); + } + } + } +} diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php index bb0b44b34..5cb64910f 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,12 @@ class CustomVariables implements Iterator, Countable, IcingaConfigRenderer protected $idx = array(); + /** @var array Array of values to be used as whitelist */ + private $whiteList = []; + + /** @var array|null */ + private ?array $cachedCustomVariableTypes = null; + protected static $allTables = array( 'icinga_command_var', 'icinga_host_var', @@ -36,6 +43,60 @@ class CustomVariables implements Iterator, Countable, IcingaConfigRenderer 'icinga_user_var', ); + /** + * Batch-fetch property value_type rows for all vars in this set. + * Stores results in $this->cachedCustomVariableTypes keyed by key_name. + * + * @param IcingaObject $object + * + * @return void + */ + protected function prefetchCustomVarTypes(IcingaObject $object): void + { + $this->cachedCustomVariableTypes = []; + + $keys = array_keys($this->vars); + if (empty($keys)) { + return; + } + + $type = $object->getShortTableName(); + $objectId = (int) $object->get('id'); + $ids = $object->listAncestorIds(); + $ids[] = $objectId; + + $query = $object->getDb()->select()->from( + ['dp' => 'director_property'], + ['key_name' => 'dp.key_name', 'value_type' => 'dp.value_type', 'object_id' => 'io.id'] + ) + ->join(['iop' => 'icinga_' . $type . '_property'], 'dp.uuid = iop.property_uuid', []) + ->join(['io' => 'icinga_' . $type], 'iop.' . $type . '_uuid = io.uuid', []) + ->joinLeft( + ['iov' => 'icinga_' . $type . '_var'], + 'iov.' . $type . '_id = io.id AND iov.varname = dp.key_name', + [] + ) + ->where('dp.key_name IN (?)', $keys) + ->where('io.id IN (?)', $ids); + + foreach ($object->getDb()->fetchAll($query) as $row) { + $existing = $this->cachedCustomVariableTypes[$row->key_name] ?? null; + + // If a row for the exact object being rendered was already cached, keep it: the + // merge-vs-assign decision in renderSingleVar() only cares whether the CURRENT + // object defines this key directly, and an arbitrary later ancestor row must not + // overwrite that regardless of the order the database happens to return rows in. + if ($existing !== null && $existing['object_id'] === $objectId) { + continue; + } + + $this->cachedCustomVariableTypes[$row->key_name] = [ + 'value_type' => $row->value_type, + 'object_id' => (int) $row->object_id, + ]; + } + } + public static function countAll($varname, Db $connection) { $db = $connection->getDbAdapter(); @@ -159,6 +220,20 @@ public function set($key, $value) return $this; } + /** + * Set the values to be used as whitelist + * + * @param array $whitelist + * + * @return $this + */ + public function setWhiteList(array $whitelist): self + { + $this->whiteList = $whitelist; + + return $this; + } + protected function refreshIndex() { $this->idx = array(); @@ -174,13 +249,18 @@ public static function loadForStoredObject(IcingaObject $object) { $db = $object->getDb(); + $type = $object->getShortTableName(); + $columns = [ + 'v.' . $type . '_id', + 'v.varname', + 'v.varvalue', + 'v.format' + ]; + + $columns[] = 'v.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 +293,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 +318,18 @@ public function storeToDb(IcingaObject $object) if ($var->hasBeenDeleted()) { $db->delete($table, $where); } elseif ($var->hasBeenModified()) { + $data = [ + 'varvalue' => $var->getDbValue(), + 'format' => $var->getDbFormat() + ]; + + if ($uuid) { + $data['property_uuid'] = Db\DbUtil::quoteBinaryCompat($uuid, $db); + } + $db->update( $table, - array( - 'varvalue' => $var->getDbValue(), - 'format' => $var->getDbFormat() - ), + $data, $where ); } @@ -341,13 +432,17 @@ public function setOverrideKeyName($name) return $this; } - public function toConfigString($renderExpressions = false) + public function toConfigString($renderExpressions = false, ?IcingaObject $object = null) { $out = ''; + if ($object !== null && ! empty($object->get('id'))) { + $this->prefetchCustomVarTypes($object); + } + foreach ($this as $key => $var) { // TODO: ctype_alnum + underscore? - $out .= $this->renderSingleVar($key, $var, $renderExpressions); + $out .= $this->renderSingleVar($key, $var, $renderExpressions, $object); } return $out; @@ -390,26 +485,51 @@ public function toLegacyConfigString() } /** + * Render the given custom variable for the object + * * @param string $key * @param CustomVariable $var * @param bool $renderExpressions + * @param ?IcingaObject $object * * @return string */ - protected function renderSingleVar($key, $var, $renderExpressions = false) + protected function renderSingleVar($key, $var, $renderExpressions = false, ?IcingaObject $object = null): string { + $var->setWhiteList($this->whiteList); if ($key === $this->overrideKeyName) { return c::renderKeyOperatorValue( $this->renderKeyName($key), '+=', $var->toConfigStringPrefetchable($renderExpressions) ); - } else { + } + + if ($object === null || empty($object->get('id')) || ! ($var instanceof CustomVariable)) { return c::renderKeyValue( $this->renderKeyName($key), $var->toConfigStringPrefetchable($renderExpressions) ); } + + $objectId = $object->get('id'); + $cachedRow = $this->cachedCustomVariableTypes[$key] ?? null; + if ( + $cachedRow !== null + && $cachedRow['value_type'] === 'dynamic-dictionary' + && (int) $objectId !== $cachedRow['object_id'] + ) { + return c::renderKeyOperatorValue( + $this->renderKeyName($key), + '+=', + $var->toConfigStringPrefetchable($renderExpressions) + ); + } + + return c::renderKeyValue( + $this->renderKeyName($key), + $var->toConfigStringPrefetchable($renderExpressions) + ); } protected function renderKeyName($key) @@ -475,6 +595,23 @@ public function __unset($key) $this->refreshIndex(); } + /** + * Register the UUID of the given variable + * + * @param string $key + * @param UuidInterface $uuid + * + * @return $this + */ + 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..49c708ff5 --- /dev/null +++ b/library/Director/Data/CustomVariableReferenceLoader.php @@ -0,0 +1,60 @@ +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', + 'f.required', + ])->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/Db/DbObject.php b/library/Director/Data/Db/DbObject.php index 11b94c7a8..d17e2aad5 100644 --- a/library/Director/Data/Db/DbObject.php +++ b/library/Director/Data/Db/DbObject.php @@ -681,15 +681,16 @@ public function getId() */ public function getAutoincId() { - if (isset($this->properties[$this->autoincKeyName])) { + if ($this->autoincKeyName && isset($this->properties[$this->autoincKeyName])) { return (int) $this->properties[$this->autoincKeyName]; } + return null; } protected function forgetAutoincId() { - if (isset($this->properties[$this->autoincKeyName])) { + if ($this->autoincKeyName && isset($this->properties[$this->autoincKeyName])) { $this->properties[$this->autoincKeyName] = null; } diff --git a/library/Director/Data/Exporter.php b/library/Director/Data/Exporter.php index 1a3cfcb7d..a6224703b 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(); } } @@ -175,6 +180,14 @@ protected function appendTypeSpecificRelations(array &$props, DbObject $object) $props['services'] = $services; } + } elseif ($object instanceof DirectorProperty) { + // export() walks the child properties recursively, something exportDbObject() + // has no notion of since items aren't a plain DB column. + $bespokeExport = $object->export(); + $props['items'] = $bespokeExport->items; + if (isset($bespokeExport->datalist)) { + $props['datalist'] = $bespokeExport->datalist; + } } } @@ -274,6 +287,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 +316,7 @@ protected function exportIcingaObject(IcingaObject $object) { $props = (array) $object->toPlainObject($this->resolveObjects, !$this->showDefaults); if ($object->supportsFields()) { + $props['customVariables'] = $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..cc1da2952 100644 --- a/library/Director/Data/ObjectImporter.php +++ b/library/Director/Data/ObjectImporter.php @@ -56,6 +56,12 @@ public function import(string $implementation, stdClass $plain): DbObject $properties = (array) $plain; unset($properties['fields']); unset($properties['originalId']); + unset($properties['customVariables']); + // A DirectorProperty's items/datalist aren't plain DB columns, setProperties() would + // reject them. BasketDiff restores the snapshot's own items/datalist afterwards. + unset($properties['items']); + unset($properties['datalist']); + if ($implementation === Basket::class) { if (isset($properties['objects']) && is_string($properties['objects'])) { $properties['objects'] = JsonString::decode($properties['objects']); @@ -91,6 +97,7 @@ protected function fixSubObjects(string $implementation, stdClass $plain) if ($implementation === IcingaServiceSet::class) { foreach ($plain->services as $service) { unset($service->fields); + unset($service->customVariables); } // Hint: legacy baskets are carrying service names as object keys, new baskets have arrays $plain->services = array_values((array) $plain->services); diff --git a/library/Director/Db.php b/library/Director/Db.php index 09c34f339..fa4ce1d33 100644 --- a/library/Director/Db.php +++ b/library/Director/Db.php @@ -4,13 +4,13 @@ use DateTime; use DateTimeZone; -use Exception; use Icinga\Data\ResourceFactory; use Icinga\Exception\ConfigurationError; use Icinga\Module\Director\Data\Db\DbConnection; use Icinga\Module\Director\Objects\IcingaEndpoint; use Icinga\Module\Director\Objects\IcingaObject; use RuntimeException; +use Throwable; use Zend_Db_Select; class Db extends DbConnection @@ -27,9 +27,13 @@ protected function db() } /** + * Run a callable inside a transaction, rolling back if anything goes wrong + * * @param $callable + * * @return $this - * @throws Exception + * + * @throws Throwable */ public function runFailSafeTransaction($callable) { @@ -42,10 +46,10 @@ public function runFailSafeTransaction($callable) try { $callable(); $db->commit(); - } catch (Exception $e) { + } catch (Throwable $e) { try { $db->rollback(); - } catch (Exception $e) { + } catch (Throwable $e) { // Well... there is nothing we can do here. } throw $e; diff --git a/library/Director/Db/DbUtil.php b/library/Director/Db/DbUtil.php index 1dacbef0c..f91972b72 100644 --- a/library/Director/Db/DbUtil.php +++ b/library/Director/Db/DbUtil.php @@ -25,6 +25,26 @@ public static function binaryResult($value) return $value; } + /** + * Normalize all stream resources in an associative row array to raw binary strings. + * + * PostgreSQL returns bytea columns as PHP stream resources; calling this once at the + * fetch boundary avoids scattered binaryResult() calls throughout business logic. + * + * @param array $row + * + * @return array + */ + public static function normalizeRow(array $row): array + { + foreach ($row as $key => $value) { + if (is_resource($value)) { + $row[$key] = stream_get_contents($value); + } + } + + return $row; + } /** * @param string|array $binary diff --git a/library/Director/DirectorObject/Automation/Basket.php b/library/Director/DirectorObject/Automation/Basket.php index cfa71e7ef..14ce01aaf 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['CustomVariable']);// 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..7f5fb12d3 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,9 @@ protected function getBasket($type, $key): stdClass { $object = $this->getBasketObject($type, $key); $fields = $object->fields ?? null; + $customVariables = $object->customVariables ?? null; + $items = $object->items ?? null; + $datalist = $object->datalist ?? null; $reExport = $this->exporter->export( $this->importer->import(BasketSnapshot::getClassForType($type), $object) ); @@ -86,6 +106,28 @@ protected function getBasket($type, $key): stdClass } else { $reExport->fields = $fields; } + + if ($customVariables === null) { + unset($reExport->customVariables); + } else { + $reExport->customVariables = $customVariables; + } + + // A CustomVariable's items/datalist must reflect what the snapshot actually recorded, + // not the live DB row importer/exporter above just re-read, otherwise a child added + // or removed since the snapshot was taken would never show up in the diff. + if ($items === null) { + unset($reExport->items); + } else { + $reExport->items = $items; + } + + if ($datalist === null) { + unset($reExport->datalist); + } else { + $reExport->datalist = $datalist; + } + CompareBasketObject::normalize($reExport); return $reExport; diff --git a/library/Director/DirectorObject/Automation/BasketSnapshot.php b/library/Director/DirectorObject/Automation/BasketSnapshot.php index 96fb2d582..f65f6106c 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['CustomVariable'] = $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..29617dd78 --- /dev/null +++ b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php @@ -0,0 +1,371 @@ +objects = (array) $objects; + $this->targetDb = $targetDb; + } + + /** + * Load all custom properties from the DB. + * + * @param Db $db + * + * @return array + */ + public function loadCurrentProperties(Db $db): array + { + $properties = []; + foreach ($this->getRequiredUuids() as $uuid) { + $property = DirectorProperty::loadWithUniqueId(Uuid::fromString($uuid), $db); + if ($property !== null) { + $properties[$uuid] = $property; + } + } + + 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(); + } + } + + $this->newPropertiesStored = true; + } + + /** + * Relink custom properties to the new object. + * + * @param IcingaObject $new + * @param $object + * + * @return void + */ + public function relinkObjectCustomProperties(IcingaObject $new, $object): void + { + if (! $new->supportsCustomProperties() || ! isset($object->customVariables)) { + return; + } + + $this->assertPropertiesHaveBeenStored(); + + $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; + } + + $targetProperties = $this->getTargetProperties(); + foreach ($object->customVariables as $property) { + $propertyUuid = DbUtil::binaryResult($property->property_uuid); + if (! isset($customPropertyMap[$propertyUuid])) { + throw new InvalidArgumentException( + 'Basket Snapshot contains invalid custom variable reference: ' . $propertyUuid + ); + } + + $uuid = $customPropertyMap[$propertyUuid]; + + if (isset($existingCustomProperties[$uuid])) { + $db->update( + $table, + ['required' => $property->required ?? 'n'], + $db->quoteInto( + "$objectKey = $objectUuid AND property_uuid = ?", + DbUtil::quoteBinaryCompat(Uuid::fromString($uuid)->getBytes(), $db) + ) + ); + unset($existingCustomProperties[$uuid]); + } else { + $db->insert($table, [ + $objectKey => DbUtil::quoteBinaryCompat($new->get('uuid'), $db), + 'property_uuid' => DbUtil::quoteBinaryCompat(Uuid::fromString($uuid)->getBytes(), $db), + 'required' => $property->required ?? 'n', + ]); + } + + if (! isset($targetProperties[$propertyUuid])) { + throw new InvalidArgumentException( + 'Basket Snapshot contains invalid custom variable reference: ' . $propertyUuid + ); + } + + $new->vars()->registerVarUuid($targetProperties[$propertyUuid]->get('key_name'), Uuid::fromString($uuid)); + } + + $new->vars()->storeToDb($new); + + if (! empty($existingCustomProperties)) { + $existingCustomPropertyUuids = array_map( + fn($uuid) => Uuid::fromString($uuid)->getBytes(), + array_keys($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->customVariables)) { + return; + } + + $forward = $this->getUuidMap(); + $map = array_flip($forward); + foreach ($object->customVariables 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->customVariables)) { + continue; + } + + foreach ($object->customVariables as $property) { + $uuids[$property->property_uuid] = true; + } + } + } + + $this->requiredUuids = array_keys($uuids); + + return $this->requiredUuids; + } + + /** + * Assert that new properties have already been persisted + * + * calculateUuidMap() assigns a placeholder uuid to a property that has + * not been stored yet. That placeholder is only corrected once + * storeNewProperties() runs and writes back the real uuid, so relinking + * before that point would write a link that points at nothing. + * + * @throws ProgrammingError + */ + protected function assertPropertiesHaveBeenStored(): void + { + if (! $this->newPropertiesStored && ! empty($this->getObjectsByType('CustomVariable'))) { + throw new ProgrammingError( + 'storeNewProperties() must run before custom properties can be relinked' + ); + } + } + + /** + * 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) { + // import() prepares the object but does not persist it; $new->get('uuid') may be a + // freshly generated UUID that is only valid after storeNewProperties() is called. + $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; + $keep = []; + + foreach ($property->fetchItemsFromDb() as $item) { + $itemUuid = $item->get('uuid'); + if ($itemUuid !== null) { + $keep[$itemUuid] = true; + } + + if ($item->hasBeenModified()) { + $item->store(); + $modified = true; + } + + if ($this->restoreCustomPropertyItems($item)) { + $modified = true; + } + } + + foreach ($property->fetchExistingChildrenFromDb() as $existingChild) { + if (! isset($keep[$existingChild->get('uuid')])) { + $existingChild->delete(); + $modified = true; + } + } + + 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..82b8adb54 100644 --- a/library/Director/IcingaConfig/IcingaConfig.php +++ b/library/Director/IcingaConfig/IcingaConfig.php @@ -566,10 +566,16 @@ protected function renderHostOverridableVars() globals.directorWarnOnceForServiceWithoutHost() } + var overriddenVar = name + if (vars.overriddenVar) { + overriddenVar = vars.overriddenVar + vars.remove("overriddenVar") + } + if (vars) { - vars += host.vars[DirectorOverrideVars][name] + vars += host.vars[DirectorOverrideVars][overriddenVar] } else { - vars = host.vars[DirectorOverrideVars][name] + vars = host.vars[DirectorOverrideVars][overriddenVar] } } } diff --git a/library/Director/IcingaConfig/IcingaConfigHelper.php b/library/Director/IcingaConfig/IcingaConfigHelper.php index 5d44bfaee..a2872347c 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,46 @@ 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 ($whiteList === null) { + return $hasMacroPattern; + } + + // A string key maps a legacy macro alias to the name it should be rendered as, + // e.g. ['config' => 'value'] keeps a pre-rename $config$ macro resolving correctly. + if (array_key_exists($name, $whiteList) || in_array($name, $whiteList, true)) { + return true; + } + + foreach ($whiteList as $pattern) { + if (! is_string($pattern) || ! str_contains($pattern, '*')) { + continue; + } + + $regexBody = preg_quote($pattern, '/'); + // value[*] matches a numeric index or a quoted key, e.g. value["on call contact"]. + // Both '"' and '\' are excluded from the quoted key so it can't be escaped early. + $regexBody = str_replace('\[\*\]', '\[(?:\d+|"[^"\\\\]*")\]', $regexBody); + // Any other wildcard matches exactly one macro-name segment, not arbitrary text. + $regexBody = str_replace('\*', '[A-Za-z_.\d]+', $regexBody); + + if (preg_match('/^' . $regexBody . '$/', $name)) { + return true; + } + } + + return false; } public static function renderStringWithVariables($string, ?array $whiteList = null) @@ -402,16 +440,16 @@ 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) + ); } + + $alias = $whiteList[$macroName] ?? null; + $parts[] = is_string($alias) ? $alias : $macroName; + $offset = $i + 1; } $start = false; diff --git a/library/Director/Objects/DirectorDatafield.php b/library/Director/Objects/DirectorDatafield.php index 65781d33a..eb964e18d 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,40 @@ public function getFormElement(DirectorObjectForm $form, $name = null): ?ZfEleme $el->setLabel($caption); } - if ($description = $this->get('description')) { + $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 variable 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..3697f866c 100644 --- a/library/Director/Objects/DirectorDatalist.php +++ b/library/Director/Objects/DirectorDatalist.php @@ -5,6 +5,7 @@ use Exception; use Icinga\Module\Director\Data\Db\DbObject; use Icinga\Module\Director\DataType\DataTypeDatalist; +use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; use Icinga\Module\Director\Exception\DuplicateKeyException; @@ -115,10 +116,24 @@ protected function isInUse(): bool ->where('datatype = ?', DataTypeDatalist::class) ->where('setting_value = ?', $id); + $quoteUuid = DbUtil::quoteBinaryCompat($this->get('uuid'), $db); + $customPropertiesCheck = $db->select() + ->from(['dp' => 'director_property'], ['key_name']) + ->join( + ['dpl' => 'director_property_datalist'], + 'dp.uuid = dpl.property_uuid', + [] + ) + ->where('dpl.list_uuid = ?', $quoteUuid); + 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..618b3c5e2 --- /dev/null +++ b/library/Director/Objects/DirectorProperty.php @@ -0,0 +1,630 @@ + 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) + { + if (! is_array($properties)) { + $properties = (array) $properties; + } + + return parent::setDbProperties($this->stripVirtualParentUuidColumn($properties)); + } + + public function setProperties($props) + { + return parent::setProperties($this->stripVirtualParentUuidColumn($props)); + } + + /** + * MySQL exposes parent_uuid_v as a virtual/generated column; strip it before it reaches + * the parent's property handling, which knows nothing about it. Needs a better solution. + * + * @param array $properties + * + * @return array + */ + private function stripVirtualParentUuidColumn(array $properties): array + { + $connection = $this->getConnection(); + if ($connection && $connection->isMysql() && isset($properties['parent_uuid_v'])) { + unset($properties['parent_uuid_v']); + } + + return $properties; + } + + /** + * 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 { + $categoryName = $category; + $category = DirectorDatafieldCategory::loadOptional($category, $this->getConnection()); + if ($category) { + $this->setCategory($category); + } else { + $this->setCategory(DirectorDatafieldCategory::create( + ['category_name' => $categoryName], + $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; + } + + /** + * Re-query this property's children directly from the database, bypassing the + * $items cache fetchItemsFromDb() may already hold with just a snapshot's own + * children, as a basket import leaves it + * + * @return DirectorProperty[] + */ + public function fetchExistingChildrenFromDb(): array + { + $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) + ); + + return DirectorProperty::loadAll($this->connection, $query); + } + + 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) + )); + $listName = $this->db->fetchOne($query); + if ($listName) { + $this->datalist = DirectorDatalist::load($listName, $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; + } + + + /** + * Resolve a datalist referenced by name during basket import + * + * If no datalist with this name exists yet in the target database, a new one is created + * and persisted immediately, so that it has a uuid by the time onStore() links it to this + * property, and so that sibling properties referencing the same not-yet-existing list name + * resolve to the very same row instead of each creating their own duplicate. + * + * @param mixed $datalistName + * @param Db $db + * + * @return ?DirectorDatalist + */ + private static function resolveImportedDatalist($datalistName, Db $db): ?DirectorDatalist + { + $datalist = DirectorDatalist::loadOptional($datalistName, $db); + if (! $datalist && is_string($datalistName)) { + $datalist = DirectorDatalist::create([ + 'list_name' => $datalistName, + 'owner' => static::currentUsername(), + ], $db); + $datalist->store($db); + } + + return $datalist; + } + + private static function currentUsername(): string + { + $auth = Auth::getInstance(); + + return $auth->isAuthenticated() ? $auth->getUser()->getUsername() : ''; + } + + /** + * @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 = static::resolveImportedDatalist($plain->datalist, $db); + unset($plain->datalist); + } + + $candidate = DirectorProperty::loadWithUniqueId($uuid, $db); + if ($candidate) { + assert($candidate instanceof DirectorProperty); + if (isset($plain->parent_uuid)) { + $plain->parent_uuid = Uuid::fromString($plain->parent_uuid)->getBytes(); + } + + $candidate->setProperties((array) $plain); + if ($datalist) { + $candidate->datalist = $datalist; + } + + $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)) { + $exportParent = DirectorProperty::loadWithUniqueId(Uuid::fromString($export->parent_uuid), $db); + if ($exportParent === null) { + // Parent no longer exists (orphaned reference, no FK enforces this link); + // leave parent_uuid in place instead of crashing on a null dereference. + } else { + $export->parent = $exportParent->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) { + // $export's parent_uuid is already a UUID string at this point (see + // export()); match that representation here too, or the equals() call + // below tries to JSON-encode raw binary bytes and crashes. The raw bytes + // form is restored a few lines down before create() needs it. + unset($plain->parent); + $plain->parent_uuid = Uuid::fromBytes($plainParentUuid)->toString(); + } 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; + } + + /** + * @throws InvalidArgumentException if a nested property is being stored with a value_type + * that may only be used at the top level, a dynamic-array + * is nested inside another dynamic-array, a 'sensitive' + * item type is used under a dynamic-array or datalist, or a + * property is being (re)typed into a dynamic-array or + * datalist while it still has a 'sensitive' child + */ + protected function beforeStore(): void + { + $this->persistPendingCategory(); + + $valueType = $this->get('value_type'); + + // The child-side check further down only fires when the child itself gets stored. + // Switching an existing dynamic-dictionary (or similar) to a dynamic-array or + // datalist skips that check entirely, so a sensitive child already sitting there + // would suddenly start rendering in the clear. Catch that here too. + if (in_array($valueType, self::UNMASKED_LIST_TYPES, true)) { + foreach ($this->fetchExistingChildrenFromDb() as $child) { + if ($child->get('value_type') === 'sensitive') { + throw new InvalidArgumentException(sprintf( + "'%s' cannot be used here, this property has a 'sensitive' child which" + . " would then be rendered in the clear", + $valueType + )); + } + } + } + + $parentUuid = $this->get('parent_uuid'); + if ($parentUuid === null) { + return; + } + + if (in_array($valueType, self::NON_NESTABLE_TYPES, true)) { + throw new InvalidArgumentException(sprintf( + "'%s' can only be used as a top-level custom variable; it cannot be nested inside" + . " a fixed-array, fixed-dictionary, dynamic-array or dynamic-dictionary", + $valueType + )); + } + + if ($valueType === 'dynamic-array' || $valueType === 'sensitive') { + $parent = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $this->connection); + $parentValueType = $parent?->get('value_type'); + + // dynamic-array may be nested inside a fixed-array, fixed-dictionary or + // dynamic-dictionary field, or declare a datalist's item type, but it cannot + // nest inside itself (a dynamic-array of dynamic-arrays). + if ($valueType === 'dynamic-array' && $parentValueType === 'dynamic-array') { + throw new InvalidArgumentException( + "'dynamic-array' cannot be nested inside another dynamic-array" + ); + } + + // A dynamic-array or datalist shows every entry in the clear, so a sensitive + // item here could never actually be hidden. + if ($valueType === 'sensitive' && in_array($parentValueType, self::UNMASKED_LIST_TYPES, true)) { + throw new InvalidArgumentException( + "'sensitive' cannot be used as the item type of a dynamic-array or datalist" + ); + } + } + } + + /** + * A brand new category can end up hanging around only in memory, never + * actually saved. That happens on a basket restore hitting a fresh DB, + * the category name doesn't exist there yet. Save it here and link it, + * so we don't just lose the name. + * + * @return void + */ + private function persistPendingCategory(): void + { + if ($this->category === null || $this->category->hasBeenLoadedFromDb()) { + return; + } + + // A sibling property may have created the very same category a moment ago, + // in the same restore, check again before inserting a duplicate name. + $existing = DirectorDatafieldCategory::loadOptional( + $this->category->get('category_name'), + $this->getConnection() + ); + + if ($existing) { + $this->category = $existing; + } else { + $this->category->store(); + } + + $this->set('category_id', $this->category->get('id')); + } + + protected function onStore(): void + { + $db = $this->db; + $propertyUuid = Db\DbUtil::quoteBinaryCompat($this->get('uuid'), $db); + // Fetch the datalist before deleting the link row + $datalist = $this->getDatalist(); + $db->delete( + 'director_property_datalist', + $db->quoteInto('property_uuid = ?', $propertyUuid) + ); + + if ($datalist) { + $db->insert( + 'director_property_datalist', + [ + 'property_uuid' => $propertyUuid, + 'list_uuid' => Db\DbUtil::quoteBinaryCompat($datalist->get('uuid'), $db), + ] + ); + } + } + + /** + * 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(); + } + + $child = DirectorProperty::import($value, $db); + if ($nestedItems) { + $child->items = $this->importItems($nestedItems, $db); + } + + $itemCandidates[$key] = $child; + + 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 = static::resolveImportedDatalist($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..8cb03277b 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 $supportsCustomVariables = true; + protected $supportsImports = true; protected $supportedInLegacy = true; diff --git a/library/Director/Objects/IcingaHost.php b/library/Director/Objects/IcingaHost.php index 7387eb275..8a8a987b2 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 $supportsCustomVariables = 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..39c19fc7c 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 $supportsCustomVariables = true; + protected $supportsImports = true; protected $supportsApplyRules = true; diff --git a/library/Director/Objects/IcingaObject.php b/library/Director/Objects/IcingaObject.php index 9f2ebbfae..de4ec48c7 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 Variables */ + protected $supportsCustomVariables = 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->supportsCustomVariables; + } + /** * Whether there exist Groups for this object type * @@ -1310,6 +1324,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(); @@ -1326,7 +1345,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; } @@ -1338,9 +1362,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; + } } } @@ -1349,7 +1399,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); @@ -1444,6 +1508,38 @@ public function vars() return $this->vars; } + public function fetchAllLinkedCustomProperties(): array + { + $type = $this->getShortTableName(); + $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_{$type}_property"], + 'dp.uuid = iop.property_uuid', + [] + )->join( + ['io' => "icinga_{$type}"], + "iop.{$type}_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 */ @@ -2216,7 +2312,7 @@ protected function renderLegacySuffix() protected function renderCustomVars() { if ($this->supportsCustomVars()) { - return $this->vars()->toConfigString($this->isApplyRule()); + return $this->vars()->toConfigString($this->isApplyRule(), $this); } return ''; diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index 7aa7a239c..bfb37efe7 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -5,6 +5,7 @@ use Icinga\Data\Filter\Filter; use Icinga\Exception\IcingaException; use Icinga\Module\Director\Data\PropertiesFilter; +use Icinga\Module\Director\DataType\DataTypeArray; use Icinga\Module\Director\Db; use Icinga\Module\Director\Db\Cache\PrefetchCache; use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; @@ -98,6 +99,8 @@ class IcingaService extends IcingaObject implements ExportInterface protected $supportsFields = true; + protected $supportsCustomVariables = true; + protected $supportsImports = true; protected $supportsApplyRules = true; @@ -122,6 +125,9 @@ class IcingaService extends IcingaObject implements ExportInterface /** @var ServiceGroupMembershipResolver */ protected $servicegroupMembershipResolver; + /** @var array|null Whitelist to be used in custom variables of apply for rule */ + protected ?array $applyForWhiteList = null; + /** * @return IcingaCommand * @throws IcingaException @@ -348,28 +354,53 @@ 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["' . addcslashes($applyForVar, '"\\') . '"]'; + } + + $propertyType = $this->fetchApplyForPropertyType($applyForVar); + $isApplyFor = $propertyType === 'dynamic-dictionary'; + $varName = c::renderString($name); if (c::stringHasMacro($name)) { - $extraName = c::renderKeyValue('name', c::renderStringWithVariables($name)); + $this->vars(); // populates $applyForWhiteList, needed for the legacy $config$ alias + $extraName = c::renderKeyValue( + 'name', + c::renderStringWithVariables($name, $this->applyForWhiteList) + ); $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 = $propertyType !== null + ? sprintf("\n vars.overriddenVar = %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 +418,60 @@ protected function getLegacyObjectKeyName() } } + /** + * Fetch property type of the custom variable used in apply for rule + * + * @param string $applyFor + * + * @return ?string + */ + protected function fetchApplyForPropertyType(string $applyFor): ?string + { + // Data field names and Custom Variable names are independent namespaces. + // An Apply For rule created long before Custom Variables existed may use a + // Data field name that a wholly unrelated Custom Variable now happens to + // share, on some unrelated host template. The Data field must keep winning, + // or an existing, already-deployed Apply For rule would silently start + // rendering as a dictionary iteration it was never meant to be. + if ($this->hasLegacyArrayDatafield($applyFor)) { + return null; + } + + // key_name is unique among root properties, a DB constraint enforces it on + // both engines, so this can only ever match one row. The host_property join + // just weeds out a key_name nobody has attached anywhere. + $query = $this->db + ->select() + ->from(['dp' => 'director_property'], ['value_type' => 'dp.value_type']) + ->join(['iop' => 'icinga_host_property'], 'dp.uuid = iop.property_uuid', []) + ->where('dp.parent_uuid IS NULL') + ->where('dp.key_name = ?', $applyFor); + + $result = $this->db->fetchOne($query); + + return $result === false ? null : $result; + } + + /** + * Whether a deprecated Data field of an array-like type with this name is + * already attached to some host template + * + * @param string $varName + * + * @return bool + */ + protected function hasLegacyArrayDatafield(string $varName): bool + { + $query = $this->db + ->select() + ->from(['df' => 'director_datafield'], ['varname' => 'df.varname']) + ->join(['ihf' => 'icinga_host_field'], 'df.id = ihf.datafield_id', []) + ->where('df.varname = ?', $varName) + ->where('df.datatype = ?', DataTypeArray::class); + + return (bool) $this->db->fetchOne($query); + } + protected function rendersConditionalTemplate(): bool { return $this->getRenderingZone() === self::ALL_NON_GLOBAL_ZONES; @@ -624,6 +709,41 @@ public function createWhere() return $where; } + public function vars() + { + $vars = parent::vars(); + if ($this->connection === null || ! $this->isApplyRule()) { + return $vars; + } + + $applyFor = substr($this->get('apply_for') ?? '', strlen('host.vars.')); + if (empty($applyFor)) { + return $vars; + } + + if ($this->applyForWhiteList === null) { + $isApplyForDictionary = $this->fetchApplyForPropertyType($applyFor) === 'dynamic-dictionary'; + $whiteList = ['value', 'host.*']; + if ($isApplyForDictionary) { + $whiteList[] = 'key'; + $whiteList[] = 'value[*]'; + $whiteList[] = 'value[*].*'; + $whiteList[] = 'value.*'; + } else { + // Legacy alias: before the loop variable was renamed, custom variable + // strings could reference the plain-array apply-for loop value as $config$. + // Those stored strings survive an upgrade unchanged, so keep resolving them + // to what is now called "value" instead of silently leaving them literal. + $whiteList['config'] = 'value'; + } + + $this->applyForWhiteList = $whiteList; + } + + $vars->setWhiteList($this->applyForWhiteList); + + return $vars; + } /** * 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 0195d022d..c7df64240 100644 --- a/library/Director/Objects/IcingaUser.php +++ b/library/Director/Objects/IcingaUser.php @@ -34,6 +34,8 @@ class IcingaUser extends IcingaObject implements ExportInterface protected $supportsFields = true; + protected $supportsCustomVariables = true; + protected $supportsImports = true; protected $booleans = array( diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index 933fb33cc..0b6a3c913 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -5,11 +5,15 @@ use Icinga\Application\Config; use Icinga\Exception\ConfigurationError; use Icinga\Exception\NotFoundError; -use Icinga\Module\Director\CustomVariable\CustomVariableArray; +use Icinga\Module\Director\CustomVariable\CustomVariable; +use Icinga\Module\Director\CustomVariable\CustomVariableDictionary; 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\Db\DbUtil; 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 +28,7 @@ use ipl\Html\Text; use ipl\Html\ValidHtml; use ipl\Orm\Model; +use PDO; use Throwable; class CustomVarRenderer extends CustomVarRendererHook @@ -31,12 +36,36 @@ class CustomVarRenderer extends CustomVarRendererHook /** @var array Related datafield configuration */ protected $fieldConfig = []; + /** @var array Related custom variable configuration */ + protected $customVariableConfig = []; + /** @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 = []; + + /** + * Dictionary child configuration, keyed by full ancestor scope (see scopeKey()) + * then by child key_name. Two unrelated dictionaries can share a child name with + * different sensitivity, so never key or look this up by name alone. + * + * @var array + */ + protected $dictionaryChildConfig = []; + + /** + * Sensitive item positions within fixed-array properties, keyed by ancestor + * scope (see scopeKey()) then position, e.g. ['ssh_args' => ['3' => true]]. + * Same scoping as $dictionaryChildConfig, same reason. + * + * @var array + */ + protected $sensitiveArrayItems = []; + protected $dictionaryLevel = 0; /** @var HtmlElement Table for dictionary fields */ @@ -62,6 +91,38 @@ protected function db(): Db return Db::fromResourceName($resourceName); } + /** + * Whether $serviceName is one of the objects generated by the apply-for rule + * iterating $hostVar + * + * Icinga 2 suffixes generated names with the value for "for (value in array)", + * but with the key for "for (key => value in dict)". Mirrors the isApplyFor + * check in IcingaService::renderObjectHeader(). + * + * @param CustomVariable $hostVar + * @param string $appliedServiceName + * @param string $serviceName + * + * @return bool + */ + private function isGeneratedApplyForServiceName( + CustomVariable $hostVar, + string $appliedServiceName, + string $serviceName + ): bool { + $suffixes = $hostVar instanceof CustomVariableDictionary + ? $hostVar->listKeys() + : $hostVar->getValue(); + + foreach ($suffixes as $suffix) { + if ($appliedServiceName . $suffix === $serviceName) { + return true; + } + } + + return false; + } + public function prefetchForObject(Model $object): bool { try { @@ -117,19 +178,13 @@ public function prefetchForObject(Model $object): bool if ($appliedService->apply_for === null) { $isAppliedService = $appliedService->name === $serviceName; } else { - /** @var ?CustomVariableArray $hostVar */ $hostVar = $directorHostObj->vars()->get((substr($appliedService->apply_for, 10))); - if ($hostVar) { - $appliedServiceName = $appliedService->name; - $appliedForServiceLookup = []; - foreach ($hostVar->getValue() as $val) { - $appliedForServiceLookup[$appliedServiceName . $val] = true; - } - - $isAppliedService = isset($appliedForServiceLookup[$serviceName]); - } else { - $isAppliedService = false; - } + $isAppliedService = $hostVar !== null + && $this->isGeneratedApplyForServiceName( + $hostVar, + $appliedService->name, + $serviceName + ); } if (! $isAppliedService) { @@ -194,10 +249,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 +292,61 @@ public function prefetchForObject(Model $object): bool } } + if ($service === null) { + $customProperties = $this->getObjectCustomProperties($directorHostObj); + } else { + $customProperties = $this->getObjectCustomProperties($directorServiceObj); + } + + if (empty($customProperties)) { + return true; + } + + foreach ($customProperties as $customProperty) { + $propertyName = $customProperty['key_name']; + $this->customVariableConfig[$propertyName] = ['label' => $customProperty['label']]; + if (isset($customProperty['category'])) { + $this->customVariableConfig[$propertyName]['group'] = $customProperty['category']; + } + + if ($customProperty['value_type'] === 'sensitive') { + $this->customVariableConfig[$propertyName]['visibility'] = 'hidden'; + } + + if (str_ends_with($customProperty['value_type'], '-dictionary')) { + $this->dictionaryNames[] = $propertyName; + } + } + + $this->loadNestedPropertyConfig($db, $customProperties); + + $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 +355,209 @@ public function prefetchForObject(Model $object): bool } } - public function renderCustomVarKey(string $key) + private function valueTypeOrderExpr(Db $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): array + { + if ($object->get('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'); + $dbAdapter = $db->getDbAdapter(); + $query = $dbAdapter + ->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 (?)', DbUtil::quoteBinaryCompat($uuids, $dbAdapter)) + ->group([ + 'dp.uuid', + 'dp.key_name', + 'dp.value_type', + 'dp.label', + 'cpc.category_name', + 'iop.' . $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 = []; + + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + $result[$row['key_name']] = $row; + } + + return $result; + } + + /** + * Build the scope key a property's metadata is stored and looked up under + * + * Chains every ancestor into the key instead of just the immediate parent, so + * two properties can only collide if they share their entire ancestor path. + * + * @param ?string $outerScope + * @param string $key + * + * @return string + */ + protected function scopeKey(?string $outerScope, string $key): string + { + return $outerScope === null ? $key : $outerScope . "\0" . $key; + } + + /** + * Load dictionary child and sensitive-array metadata scoped to the object's own + * property tree + * + * Custom properties nest at most one level deep, so this only walks 2 generations + * below the object's top level properties, never the whole director_property table. + * + * @param Db $db + * @param array $customProperties Top level custom properties of the rendered + * object, as returned by getObjectCustomProperties() + * + * @return void + */ + private function loadNestedPropertyConfig(Db $db, array $customProperties): void + { + $dbAdapter = $db->getDbAdapter(); + + // uuid (raw binary) => ['key_name' => ..., 'value_type' => ..., 'scope' => ?string] + $frontier = []; + foreach ($customProperties as $property) { + $frontier[DbUtil::binaryResult($property['uuid'])] = [ + 'key_name' => $property['key_name'], + 'value_type' => $property['value_type'], + 'scope' => null, + ]; + } + + for ($depth = 0; $depth < 2 && ! empty($frontier); $depth++) { + $query = $dbAdapter->select() + ->from('director_property', ['uuid', 'parent_uuid', 'key_name', 'label', 'value_type']) + ->where('parent_uuid IN (?)', DbUtil::quoteBinaryCompat(array_keys($frontier), $dbAdapter)); + + $children = []; + foreach ($dbAdapter->fetchAll($query, [], PDO::FETCH_ASSOC) as $row) { + $row = DbUtil::normalizeRow($row); + $parent = $frontier[$row['parent_uuid']] ?? null; + if ($parent === null) { + continue; + } + + $scope = $this->scopeKey($parent['scope'], $parent['key_name']); + + if ($parent['value_type'] === 'fixed-array' && $row['value_type'] === 'sensitive') { + $this->sensitiveArrayItems[$scope][$row['key_name']] = true; + } + + if (in_array($parent['value_type'], ['fixed-dictionary', 'dynamic-dictionary'], true)) { + $this->customPropertyDictionaries[$parent['key_name']][$row['key_name']] = $row['label']; + + $childConfig = ['label' => $row['label']]; + if ($row['value_type'] === 'sensitive') { + $childConfig['visibility'] = 'hidden'; + } + + $this->dictionaryChildConfig[$scope][$row['key_name']] = $childConfig; + } + + $children[$row['uuid']] = [ + 'key_name' => $row['key_name'], + 'value_type' => $row['value_type'], + 'scope' => $scope, + ]; + } + + $frontier = $children; + } + } + + /** + * Look up dictionary-child config for $key under parent $parentKey, if any + * + * @param string $key + * @param ?string $parentKey + * @param ?string $grandparentKey Set when $parentKey is itself nested one level deep + * + * @return ?array + */ + private function dictionaryChildConfigFor(string $key, ?string $parentKey, ?string $grandparentKey = null): ?array + { + if ($parentKey === null) { + return null; + } + + return $this->dictionaryChildConfig[$this->scopeKey($grandparentKey, $parentKey)][$key] ?? null; + } + + public function renderCustomVarKey(string $key, ?string $parentKey = null, ?string $grandparentKey = null) { 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->dictionaryChildConfigFor($key, $parentKey, $grandparentKey)['label'] + ?? $this->customVariableConfig[$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()); } @@ -266,40 +565,56 @@ public function renderCustomVarKey(string $key) return null; } - public function renderCustomVarValue(string $key, $value) - { + public function renderCustomVarValue( + string $key, + $value, + ?string $parentKey = null, + ?string $grandparentKey = null + ): mixed { + $childConfig = $this->dictionaryChildConfigFor($key, $parentKey, $grandparentKey); + + if (! (isset($this->fieldConfig[$key]) || $childConfig !== null || isset($this->customVariableConfig[$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; - } - } + $visibility = $childConfig['visibility'] ?? $this->customVariableConfig[$key]['visibility'] ?? null; + if ($visibility === 'hidden') { + return '***'; + } - return $renderedValue; + if (is_array($value) && ! isset($this->customPropertyDictionaries[$key])) { + $renderedValue = []; + $arrayScope = $this->scopeKey($parentKey, $key); + foreach ($value as $k => $v) { + if (isset($this->sensitiveArrayItems[$arrayScope][$k])) { + $renderedValue[$k] = '***'; + } elseif (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; + } } - 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 +627,8 @@ public function identifyCustomVarGroup(string $key): ?string { if (isset($this->fieldConfig[$key]['group'])) { return $this->fieldConfig[$key]['group']; + } elseif (isset($this->customVariableConfig[$key]['group'])) { + return $this->customVariableConfig[$key]['group']; } return null; @@ -354,8 +671,16 @@ 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 may be a genuine nested array (fixed-/dynamic-array), not a nested + // dictionary - mask any sensitive positions it holds using its own key ($k) + // before its items are iterated below by position/child key, since those + // per-item lookups have no way to recover the parent array's own key_name. + if (is_array($val) && ! isset($this->customPropertyDictionaries[$k])) { + $val = $this->renderCustomVarValue($k, $val, $key) ?? $val; + } + $val = (array) $val; $numChildItems = count($val); @@ -363,7 +688,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, @@ -374,37 +699,44 @@ 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)) - ) - ); - } + $childVal = $this->renderCustomVarValue($childKey, $childVal, $k, $key) ?? $childVal; + $label = $this->renderCustomVarKey($childKey, $k, $key) ?? $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($k, $val, $k); } 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($k, $key) ?? Html::wantHtml($k)), + new HtmlElement( + 'td', + null, + $this->renderCustomVarValue($k, $val, $key) ?? Html::wantHtml($val) + ) ) ); } @@ -420,23 +752,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 +791,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/CustomVarApplyRequest.php b/library/Director/RestApi/CustomVarApplyRequest.php new file mode 100644 index 000000000..3ae61c2af --- /dev/null +++ b/library/Director/RestApi/CustomVarApplyRequest.php @@ -0,0 +1,28 @@ + value + * @param string $actionName Endpoint name, e.g. "index" or "variables" + * @param string $method HTTP method, "POST" or "PUT" + * @param bool $replaceAll Whether this is a full "vars" replacement + */ + public function __construct( + public IcingaObject $object, + public array $overRiddenCustomVars, + public string $actionName, + public string $method, + public bool $replaceAll + ) { + } +} diff --git a/library/Director/RestApi/CustomVariableValueApplier.php b/library/Director/RestApi/CustomVariableValueApplier.php new file mode 100644 index 000000000..f3d3d313c --- /dev/null +++ b/library/Director/RestApi/CustomVariableValueApplier.php @@ -0,0 +1,294 @@ +object; + $dbAdapter = $this->db->getDbAdapter(); + $type = $object->getShortTableName(); + $objectVars = $object->vars(); + $wipeValuesInDb = $request->method === 'PUT' && $object->get('id'); + // A PUT on the base object endpoint must still fully replace values but must not detach + // properties that were not part of this request. + $wipePropertyAttachmentsInDb = $wipeValuesInDb && $request->actionName === 'variables'; + + // If a caller already opened a transaction (e.g. IcingaObjectHandler wrapping + // object persistence and this call together), let it own the commit/rollback. + // A nested beginTransaction() call on the same PDO connection throws + // "There is already an active transaction" instead of nesting. + $manageTransaction = $wipeValuesInDb && ! $dbAdapter->getConnection()->inTransaction(); + + if ($manageTransaction) { + $dbAdapter->beginTransaction(); + } + + $preservedRequiredFlags = []; + + try { + if ($wipeValuesInDb) { + $objectWhere = $dbAdapter->quoteInto("{$type}_id = ?", $object->get('id')); + $dbAdapter->delete('icinga_' . $type . '_var', $objectWhere); + + if ($wipePropertyAttachmentsInDb) { + // The attachment row is about to be deleted and re-created further down, + // remember its required flag so that a values-only PUT cannot silently + // turn a required property into an optional one. + $preservedRequiredFlags = $this->getDirectPropertyRequiredFlags($object); + + $uuidExpr = DbUtil::quoteBinaryCompat( + DbUtil::binaryResult($object->get('uuid')), + $dbAdapter + ); + $dbAdapter->delete( + 'icinga_' . $type . '_property', + $dbAdapter->quoteInto("{$type}_uuid = ?", $uuidExpr) + ); + } + + $objectVars = new CustomVariables(); + } elseif ($request->replaceAll) { + $obsoleteKeys = []; + foreach ($objectVars as $key => $var) { + if (! array_key_exists($key, $request->overRiddenCustomVars)) { + $obsoleteKeys[] = $key; + } + } + + foreach ($obsoleteKeys as $key) { + unset($objectVars->$key); + } + } + + $customProperties = $this->getObjectCustomProperties($object); + + foreach ($request->overRiddenCustomVars as $key => $value) { + $this->applySingleVar( + $request, + $objectVars, + $customProperties, + $key, + $value, + $preservedRequiredFlags + ); + } + + $objectVars->storeToDb($object); + } catch (Throwable $e) { + if ($manageTransaction) { + $dbAdapter->rollBack(); + } + + throw $e; + } + + if ($manageTransaction) { + $dbAdapter->commit(); + } + } + + /** + * Apply a single key value override, attaching the underlying + * director_property to a template on the fly when needed + * + * @return void + * + * @throws NotFoundError + */ + private function applySingleVar( + CustomVarApplyRequest $request, + CustomVariables $objectVars, + array $customProperties, + string $key, + mixed $value, + array $preservedRequiredFlags = [] + ): void { + $object = $request->object; + $objectVars->set($key, $value); + $var = $objectVars->get($key); + if ($var === null) { + // A null value for a variable that was never set is a no-op + return; + } + + $var->setModified(); + + if (isset($customProperties[$key])) { + CustomVariableValueValidator::assertMatchesType($key, $value, $customProperties[$key]['value_type']); + if ($customProperties[$key]['value_type'] === 'datalist-strict') { + CustomVariableValueValidator::assertDatalistValueAllowed( + $key, + $value, + Uuid::fromBytes($customProperties[$key]['uuid']), + $this->db + ); + } + + $objectVars->registerVarUuid($key, Uuid::fromBytes($customProperties[$key]['uuid'])); + + return; + } + + if ($request->actionName !== 'variables') { + return; + } + + if (! $object->isTemplate()) { + throw new NotFoundError(sprintf( + 'The custom variable %s should be first added to one of the imported templates for this object', + $key + )); + } + + if ($request->method === 'POST') { + throw new NotFoundError(sprintf( + 'The custom variable %s should be first added to the template', + $key + )); + } + + $dbAdapter = $this->db->getDbAdapter(); + $query = $dbAdapter->select() + ->from(['dp' => 'director_property'], ['uuid', 'value_type']) + ->where('dp.key_name = ? AND dp.parent_uuid IS NULL', $key); + $propertyRow = $dbAdapter->fetchRow($query, [], PDO::FETCH_ASSOC); + + if (! $propertyRow) { + throw new NotFoundError(sprintf( + "'%s' is not configured in Icinga Director as a custom variable", + $key + )); + } + + $propertyRow = DbUtil::normalizeRow($propertyRow); + $customPropertyUuid = $propertyRow['uuid']; + + CustomVariableValueValidator::assertMatchesType($key, $value, $propertyRow['value_type']); + if ($propertyRow['value_type'] === 'datalist-strict') { + CustomVariableValueValidator::assertDatalistValueAllowed( + $key, + $value, + Uuid::fromBytes($customPropertyUuid), + $this->db + ); + } + + $type = $object->getShortTableName(); + $dbAdapter->insert('icinga_' . $type . '_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($customPropertyUuid, $dbAdapter), + $type . '_uuid' => DbUtil::quoteBinaryCompat($object->get('uuid'), $dbAdapter), + 'required' => $preservedRequiredFlags[$key] ?? 'n' + ]); + + $objectVars->registerVarUuid($key, Uuid::fromBytes($customPropertyUuid)); + } + + /** + * Get the required flags of the properties currently attached directly + * to the given object, keyed by the property's key_name + * + * @return array + */ + private function getDirectPropertyRequiredFlags(IcingaObject $object): array + { + if ($object->get('uuid') === null) { + return []; + } + + $type = $object->getShortTableName(); + $dbAdapter = $this->db->getDbAdapter(); + $uuidExpr = DbUtil::quoteBinaryCompat( + DbUtil::binaryResult($object->get('uuid')), + $dbAdapter + ); + $query = $dbAdapter->select() + ->from(['iop' => 'icinga_' . $type . '_property'], ['required' => 'iop.required']) + ->join(['dp' => 'director_property'], 'dp.uuid = iop.property_uuid', ['key_name' => 'dp.key_name']) + ->where($dbAdapter->quoteInto("iop.{$type}_uuid = ?", $uuidExpr)); + + $result = []; + foreach ($dbAdapter->fetchAll($query, [], PDO::FETCH_ASSOC) as $row) { + $result[$row['key_name']] = $row['required']; + } + + return $result; + } + + /** + * Get the custom properties linked to the given object, including + * properties inherited from its ancestors + * + * @param IcingaObject $object + * + * @return array + */ + private 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, [], PDO::FETCH_ASSOC) as $row) { + $row = DbUtil::normalizeRow($row); + $result[$row['key_name']] = $row; + } + + return $result; + } +} diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index 369fb49b5..73bda4a8a 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -15,6 +15,7 @@ use Icinga\Module\Director\Resolver\OverrideHelper; use InvalidArgumentException; use RuntimeException; +use stdClass; class IcingaObjectHandler extends RequestHandler { @@ -69,6 +70,8 @@ protected function requireJsonBody() ); } + static::assertJsonBodyIsObject($data); + return $data; } @@ -77,6 +80,45 @@ protected function getType() return $this->request->getControllerName(); } + /** + * Assert that DELETE is being used on the object endpoint itself + * + * DELETE is not defined for the variables sub resource, only for the + * whole object. + * + * @param string $actionName + * + * @throws NotFoundError + */ + public static function assertDeleteAllowed(string $actionName): void + { + if ($actionName !== 'index') { + throw new NotFoundError('Not found'); + } + } + + /** + * Assert that a decoded JSON body is a JSON object + * + * The REST API always expects a map of property names to values at the + * top level, never a plain JSON array. This throws InvalidArgumentException, + * not IcingaException, so that processApiRequest() maps it to HTTP 422 + * the same way it maps every other malformed override. + * + * @param mixed $decoded + * + * @throws InvalidArgumentException + */ + public static function assertJsonBodyIsObject(mixed $decoded): void + { + if (! $decoded instanceof stdClass) { + throw new InvalidArgumentException(sprintf( + 'Invalid JSON body, expected a JSON object, got %s', + get_debug_type($decoded) + )); + } + } + protected function processApiRequest() { try { @@ -87,11 +129,14 @@ protected function processApiRequest() } catch (DuplicateKeyException $e) { $this->sendJsonError($e, 422); return; + } catch (InvalidArgumentException $e) { + $this->sendJsonError($e, 422); + return; } catch (Exception $e) { $this->sendJsonError($e); } - if ($this->request->getActionName() !== 'index') { + if ($this->request->getActionName() !== 'index' && $this->request->getActionName() !== 'variables') { throw new NotFoundError('Not found'); } } @@ -118,72 +163,175 @@ protected function handleApiRequest() switch ($request->getMethod()) { case 'DELETE': + static::assertDeleteAllowed($this->request->getActionName()); $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'); + $allowsOverrides = $params->get('allowOverrides', false); $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(); + $method = $request->getMethod(); + + $overRiddenCustomVars = []; + $replaceAll = false; + if ($actionName === 'variables') { + if ($object === null) { + throw new InvalidArgumentException( + 'Cannot set variables, no matching object was found. Please provide a valid ' + . '"name" (and "host" for services), "uuid" or "id" parameter.' + ); } - // 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()) - ); - } + $overRiddenCustomVars = $data; + } else { + // Extract custom vars from the data + if (isset($data['vars'])) { + $overRiddenCustomVars = (array) $data['vars']; + $replaceAll = $method === 'POST'; - 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() - ); - } + unset($data['vars']); } - $this->persistChanges($object); - $this->sendJson($object->toPlainObject(false, true)); - } elseif ($allowsOverrides && $type === 'service') { - if ($request->getMethod() === 'PUT') { - throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); + foreach ($data as $key => $value) { + if (substr($key, 0, 5) === 'vars.') { + $overRiddenCustomVars[substr($key, 5)] = $value; + + unset($data[$key]); + } } - $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)); } - break; + $writeRequest = new IcingaObjectWriteRequest( + $object, + $data, + $type, + $actionName, + $method, + $replaceAll, + $overRiddenCustomVars, + $allowsOverrides, + $params + ); + + // Object persistence and custom-variable validation/application must + // succeed or fail together: without this, a request with an invalid + // custom variable could still leave unrelated object changes committed. + // persistObjectAndApplyVars() returns null once it has already sent its + // own response (the service-override branch), or the object to respond + // with otherwise. + $responseObject = null; + $db->runFailSafeTransaction(function () use (&$responseObject, $writeRequest) { + $responseObject = $this->persistObjectAndApplyVars($writeRequest); + }); + + if ($responseObject !== null) { + $this->sendJson($responseObject->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()); } } + /** + * Persist an object's own property changes and apply its custom-variable + * overrides as a single unit of work + * + * Called from within a transaction (see handleApiRequest()): a failure in the + * custom-variable step must not leave the object's own property changes + * committed on their own. + * + * @return ?IcingaObject The object to respond with, or null if a response has + * already been sent (the service-override branch) + */ + protected function persistObjectAndApplyVars(IcingaObjectWriteRequest $request): ?IcingaObject + { + $db = $this->db; + $object = $request->object; + $data = $request->data; + $type = $request->type; + + if ($request->actionName !== 'variables') { + if ($object) { + // 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 ($request->method === 'POST') { + $object->setProperties($data); + } else { + $data = array_merge([ + 'object_type' => $object->get('object_type'), + 'object_name' => $object->getObjectName() + ], $data); + $object->replaceWith(IcingaObject::createByType($type, $data, $db)); + } + + $this->persistChanges($object); + } elseif ($request->allowsOverrides && $type === 'service') { + if ($request->method === 'PUT') { + throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); + } + + $data['vars'] = $request->overRiddenCustomVars; + $this->setServiceProperties( + $request->params->getRequired('host'), + $request->params->getRequired('name'), + $data + ); + + return null; + } else { + $object = IcingaObject::createByType($type, $data, $db); + $this->persistChanges($object); + } + } + + $isVariablesPut = $request->actionName === 'variables' && $request->method === 'PUT'; + if (empty($request->overRiddenCustomVars) && ! $isVariablesPut && ! $request->replaceAll) { + return $object; + } + + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $object, + $request->overRiddenCustomVars, + $request->actionName, + $request->method, + $request->replaceAll + )); + + return IcingaObject::loadByType($type, $object->getObjectName(), $db); + } + protected function persistChanges(IcingaObject $object) { if ($object->hasBeenModified()) { diff --git a/library/Director/RestApi/IcingaObjectWriteRequest.php b/library/Director/RestApi/IcingaObjectWriteRequest.php new file mode 100644 index 000000000..c38880c5e --- /dev/null +++ b/library/Director/RestApi/IcingaObjectWriteRequest.php @@ -0,0 +1,40 @@ + value + * @param bool $allowsOverrides Whether the "allowOverrides" request param was set + * @param UrlParams $params The request's URL parameters + */ + public function __construct( + public ?IcingaObject $object, + public array $data, + public string $type, + public string $actionName, + public string $method, + public bool $replaceAll, + public array $overRiddenCustomVars, + public bool $allowsOverrides, + public UrlParams $params + ) { + } +} diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index efa9f5972..f394e0e19 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -8,19 +8,23 @@ 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\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 +40,20 @@ use Icinga\Module\Director\Web\Tabs\ObjectTabs; use Icinga\Module\Director\Web\Widget\BranchedObjectHint; use gipfl\IcingaWeb2\Link; +use Icinga\Web\Form; +use Icinga\Web\Notification; +use ipl\Html\Attributes; use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Html\ValidHtml; +use ipl\Web\Compat\Multipart; +use ipl\Web\Compat\ViewRenderer; +use ipl\Web\Url; +use ipl\Web\Widget\ButtonLink; +use PDO; +use Psr\Http\Message\ServerRequestInterface; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; @@ -62,6 +79,9 @@ abstract class ObjectController extends ActionController /** @var string|null */ protected $objectBaseUrl; + /** @var Multipart[] */ + protected array $parts = []; + public function init() { $this->enableStaticObjectLoader($this->getTableName()); @@ -103,6 +123,11 @@ protected function initializeRestApi() protected function initializeWebRequest() { + $action = $this->getRequest()->getActionName(); + if ($action === 'add-var') { + return; + } + if ($this->getRequest()->getActionName() === 'add') { $this->addSingleTab( sprintf($this->translate('Add %s'), ucfirst($this->getType())), @@ -121,6 +146,56 @@ protected function initializeWebRequest() } } + public function postDispatch(): void + { + $document = new HtmlDocument(); + if (! empty($this->parts)) { + $partSeparator = base64_encode(random_bytes(16)); + $this->getResponse() + ->setHeader('X-Icinga-Multipart-Content', $partSeparator); + $document->setSeparator("\n$partSeparator\n"); + $document->add($this->parts); + // content and controls of the controller view property must be set to null, + // so that the SimpleViewRenderer is not called in ActionController + $this->view->content = null; + $this->view->controls = null; + } else { + if (! $this->content()->isEmpty()) { + $document->prepend($this->content()); + + if (! $this->view->compact && ! $this->controls()->isEmpty()) { + $document->prepend($this->controls()); + } + } + } + + ViewRenderer::inject(); + + $this->view->document = $document; + + parent::postDispatch(); + } + + /** + * Add a part to be served as multipart-content + * + * If an id is passed the element is used as-is as the part's content. + * Otherwise (no id given) the element's content is used instead. + * + * @param ValidHtml $content + * @param string $id + * + * @return $this + */ + private function addPart(ValidHtml $content, string $id): static + { + $part = new Multipart(); + $part->add($content); + $this->parts[] = $part->setFor($id); + + return $this; + } + /** * @throws NotFoundError */ @@ -270,6 +345,51 @@ public function fieldsAction() } } + public function addVarAction(): void + { + $this->assertPermission('director/admin'); + $object = $this->requireObject(); + $this->view->title = sprintf($this->translate('Add Custom Variable: %s'), $this->object->getObjectName()); + + $addedVarUuids = $this->params->getValues('addedVarUuids'); + $requiredVarUuids = $this->params->getValues('requiredVarUuids'); + $nextSlotIndex = (int) $this->params->shift('nextSlotIndex'); + + $form = (new ObjectCustomvarForm($this->db(), $object, $addedVarUuids)) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(ObjectCustomvarForm::ON_SUBMIT, function (ObjectCustomvarForm $form) use ( + $object, + $addedVarUuids, + $requiredVarUuids, + $nextSlotIndex + ) { + $newUuid = $form->getValue('property'); + if (! in_array($newUuid, $addedVarUuids, true)) { + $addedVarUuids[] = $newUuid; + } + + if ($form->isRequired() && ! in_array($newUuid, $requiredVarUuids, true)) { + $requiredVarUuids[] = $newUuid; + } + + $redirectUrl = Url::fromPath( + 'director/' . $this->getType() . '/variables', + [ + 'uuid' => Uuid::fromBytes($object->get('uuid'))->toString(), + 'newVarUuid' => $newUuid, + 'nextSlotIndex' => $nextSlotIndex + ] + ); + $redirectUrl->getParams()->addValues('addedVarUuids', $addedVarUuids); + $redirectUrl->getParams()->addValues('requiredVarUuids', $requiredVarUuids); + + $this->redirectNow($redirectUrl); + }) + ->handleRequest($this->getServerRequest()); + + $this->content()->add($form); + } + protected function addFieldsFormAndTable($object, $type) { $form = IcingaObjectFieldForm::load() @@ -296,6 +416,698 @@ protected function addFieldsFormAndTable($object, $type) $table->renderTo($this); } + public function variablesAction(): void + { + $this->assertPermission('director/admin'); + $object = $this->requireObject(); + + $newVarUuid = $this->params->shift('newVarUuid'); + $nextSlotIndex = (int) $this->params->shift('nextSlotIndex'); + + $addedVarUuids = array_unique(array_merge( + $this->params->getValues('addedVarUuids'), + array_filter(explode(',', $this->getRequest()->getPost('addedVarUuids', ''))) + )); + + $requiredVarUuids = array_unique(array_merge( + $this->params->getValues('requiredVarUuids'), + array_filter(explode(',', $this->getRequest()->getPost('requiredVarUuids', ''))) + )); + + $form = $this->prepareCustomPropertiesForm($object, null, $addedVarUuids, $requiredVarUuids); + + $form + ->on( + CustomVariablesForm::ON_SUBMIT, + function (CustomVariablesForm $form) { + if ($form->varsHasBeenModified()) { + Notification::success( + sprintf( + $this->translate('Custom variables have been successfully saved for %s'), + $form->object->getObjectName(), + ) + ); + } else { + Notification::success($this->translate('There is nothing to change.')); + } + + $this->redirectNow(Url::fromRequest()->without( + ['addedVarUuids', 'requiredVarUuids', 'newVarUuid', 'nextSlotIndex'] + )); + } + )->on( + CustomVariablesForm::ON_REQUEST, + function ( + ServerRequestInterface $request, + CustomVariablesForm $form + ) use ( + $object, + $newVarUuid, + $nextSlotIndex, + $addedVarUuids, + $requiredVarUuids + ) { + if ($newVarUuid === null) { + return; + } + + $this->sendNewVarMultipartUpdate( + $object, + $form, + $newVarUuid, + $nextSlotIndex, + $addedVarUuids, + $requiredVarUuids + ); + $this->params->remove('addedVarUuids'); + $this->params->remove('requiredVarUuids'); + $this->getResponse()->setHeader('X-Icinga-Location-Query', $this->params->toString()); + } + )->handleRequest($this->getServerRequest()); + + if ($newVarUuid !== null) { + return; + } + + $this->prepareApplyForHeader(); + + if ($this->object->isTemplate()) { + $slotIndex = $form->getElement('properties')->getItemCount(); + + $buttonUrl = Url::fromPath( + 'director/' . $this->getType() . '/add-var', + ['uuid' => $this->getUuidFromUrl(), 'nextSlotIndex' => $slotIndex] + ); + $buttonUrl->getParams()->addValues('addedVarUuids', $addedVarUuids); + $buttonUrl->getParams()->addValues('requiredVarUuids', $requiredVarUuids); + + $this->actions()->add( + Html::tag('div', ['id' => 'add-custom-var-button', 'class' => 'add-custom-var-button'], [ + (new ButtonLink( + $this->translate('Add Custom Variable'), + $buttonUrl->getAbsoluteUrl(), + null, + ['class' => 'control-button'] + ))->openInModal() + ]) + ); + + if ($form) { + $this->content()->add($form); + } + } elseif ($form) { + $form->handleRequest($this->getServerRequest()); + $this->content()->add($form); + } + + $this->addTitle( + $this->translate('Custom Variables: %s'), + $object->getObjectName() + ); + + $this->tabs()->activate('variables'); + } + + /** + * Send a multipart update for the new custom variable. + * + * @param IcingaObject $object + * @param CustomVariablesForm $form + * @param string $newVarUuid + * @param int $nextSlotIndex + * @param array $addedVarUuids + * @param array $requiredVarUuids + * + * @return void + */ + private function sendNewVarMultipartUpdate( + IcingaObject $object, + CustomVariablesForm $form, + string $newVarUuid, + int $nextSlotIndex, + array $addedVarUuids, + array $requiredVarUuids = [] + ): void { + $type = $object->getShortTableName(); + $db = $this->db()->getDbAdapter(); + $uuidBytes = Uuid::fromString($newVarUuid)->getBytes(); + + $query = $db->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label' + ] + ) + ->where('dp.uuid = ?', DbUtil::quoteBinaryCompat($uuidBytes, $db)); + + $row = $db->fetchRow($query, fetchMode: PDO::FETCH_ASSOC); + if (! $row) { + return; + } + + $propertyData = [ + 'key_name' => $row['key_name'], + 'uuid' => DbUtil::binaryResult($row['uuid']), + 'value_type' => $row['value_type'], + 'label' => $row['label'], + 'allow_removal' => true, + 'new' => true, + 'required' => in_array($newVarUuid, $requiredVarUuids, true), + $type . '_uuid' => $object->get('uuid') + ]; + + if (isset($object->vars()->{$row['key_name']})) { + // Normalize via JSON round-trip (same as getObjectCustomProperties) so stdClass becomes array + $existingValue = json_decode( + json_encode($object->vars()->{$row['key_name']}->getValue()), + true + ); + + $datalistItemType = null; + if (in_array($row['value_type'], ['datalist-strict', 'datalist-non-strict'], true)) { + $datalistItemType = $db->fetchOne( + $db->select() + ->from(['cdp' => 'director_property'], ['value_type' => 'cdp.value_type']) + ->where('cdp.parent_uuid = ?', DbUtil::quoteBinaryCompat($row['uuid'], $db)) + ); + } + + $isCompatibleType = match (true) { + $row['value_type'] === 'string' => is_string($existingValue), + $row['value_type'] === 'number' => is_numeric($existingValue), + $row['value_type'] === 'bool' => is_bool($existingValue), + in_array($row['value_type'], ['datalist-strict', 'datalist-non-strict'], true) + => $datalistItemType === 'dynamic-array' ? is_array($existingValue) : is_string($existingValue), + default => is_array($existingValue), + }; + + if ($isCompatibleType) { + $propertyData['value'] = $existingValue; + } + } + + $newItem = $form->prepareNewPropertyRow($propertyData, $nextSlotIndex); + $newSlotIndex = $nextSlotIndex + 1; + + // Fill the slot with the new DictionaryItem + next empty slot + $slotContent = new HtmlDocument(); + $slotContent->add($newItem); + $slotContent->add(Html::tag('div', ['id' => 'new-var-slot-' . $newSlotIndex])); + $this->addPart($slotContent, 'new-var-slot-' . $nextSlotIndex); + + // Update item-count input + $itemCount = $form->createElement( + 'hidden', + 'properties[item-count]', + ['value' => $newSlotIndex] + ); + + $this->addPart( + $itemCount, + 'properties-item-count' + ); + + // Update Add Custom Variable button with new slot index + $buttonUrl = Url::fromPath( + 'director/' . $this->getType() . '/add-var', + ['uuid' => Uuid::fromBytes($object->get('uuid'))->toString(), 'nextSlotIndex' => $newSlotIndex] + ); + + $buttonUrl->getParams()->addValues('addedVarUuids', $addedVarUuids); + $buttonUrl->getParams()->addValues('requiredVarUuids', $requiredVarUuids); + + $this->addPart( + (new ButtonLink( + $this->translate('Add Custom Variable'), + $buttonUrl->getAbsoluteUrl(), + null, + ['class' => 'control-button'] + ))->openInModal(), + 'add-custom-var-button' + ); + + // Update hidden addedVarUuids/requiredVarUuids inputs so POST form submission carries them + $addedUuidsContainer = new HtmlDocument(); + $addedUuidsElement = $form->createElement( + 'hidden', + 'addedVarUuids', + [ + 'value' => implode(',', $addedVarUuids) + ] + ); + + $addedUuidsContainer->addHtml($addedUuidsElement); + $this->addPart($addedUuidsContainer, 'added-var-uuids'); + + $requiredUuidsContainer = new HtmlDocument(); + $requiredUuidsElement = $form->createElement( + 'hidden', + 'requiredVarUuids', + [ + 'value' => implode(',', $requiredVarUuids) + ] + ); + + $requiredUuidsContainer->addHtml($requiredUuidsElement); + $this->addPart($requiredUuidsContainer, 'required-var-uuids'); + } + + /** + * Prepare the Custom Properties Form for hosts, services, apply rules and service sets + * + * @param IcingaObject $object + * @param IcingaHost|null $host + * @param string[] $addedVarUuids UUID strings of properties added this session + * @param string[] $requiredVarUuids UUID strings of properties marked required this session + * + * @return ?CustomVariablesForm + */ + public function prepareCustomPropertiesForm( + IcingaObject $object, + ?IcingaHost $host = null, + array $addedVarUuids = [], + array $requiredVarUuids = [] + ): ?CustomVariablesForm { + $isOverrideVars = $host !== null; + if ($isOverrideVars) { + $storedVars = $host->getOverriddenServiceVars($object); + } else { + $storedVars = $object->getVars(); + unset($storedVars->{'_override_servicevars'}); + } + + $vars = json_decode(json_encode($storedVars), true); + $inheritedVars = json_decode(json_encode($object->getInheritedVars()), JSON_OBJECT_AS_ARRAY); + $origins = $object->getOriginsVars(); + + $objectProperties = $this->getObjectCustomProperties( + $object, + $isOverrideVars, + $addedVarUuids, + $requiredVarUuids + ); + $form = (new CustomVariablesForm($object, $objectProperties)) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->setAddedVarUuids($addedVarUuids) + ->setRequiredVarUuids($requiredVarUuids); + if (empty($objectProperties)) { + return $form; + } + + $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']]) && ! $isOverrideVars) { + $row['inherited'] = $inheritedVars[$row['key_name']]; + $row['inherited_from'] = $origins->{$row['key_name']}; + } + + $result[] = $row; + } + + $form->load($result); + + return $form; + } + + /** + * Prepare the custom variables information header for service apply for rule + * + * @return void + */ + 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; + } + + $applyForHeader = new HtmlElement('div', Attributes::create(['class' => ['apply-for-header']])); + $applyForHeaderContent = HtmlElement::create( + 'div', + Attributes::create(['class' => ['apply-for-header-content']]) + ); + + if ($fetchVar->value_type !== 'dynamic-dictionary') { + $applyForHeaderContent->addHtml( + Text::create(sprintf( + $this->translate( + 'The values of selected host variable for apply-for-rule' + . ' is accessible through %s.' + ), + '$value$' + )) + ); + + $applyForHeader->addHtml($applyForHeaderContent); + + $this->content()->addHtml($applyForHeader); + + return; + } + + $applyForHeaderContent->addHtml( + Text::create(sprintf( + $this->translate( + 'The values of selected host variable for apply-for-rule' + . ' are accessible through %s and keys through %s.' + ), + '$value$', + '$key$' + )) + ); + + $applyForHeader->addHtml($applyForHeaderContent); + + $this->content()->addHtml($applyForHeader); + + $dictionaryKeys = $this->fetchNestedDictionaryKeys(DbUtil::binaryResult($fetchVar->uuid)); + if (empty($dictionaryKeys)) { + return; + } + + $content = []; + $configVariables = new HtmlElement('table', Attributes::create(['class' => 'key-value-table'])); + foreach ($dictionaryKeys as $keyAttributes) { + 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 (preg_match('/[^a-zA-Z0-9_]/', $nestedKeyAttributes['key_name'])) { + $nestedConfig = $config . '["' . $nestedKeyAttributes['key_name'] . '"]$'; + } else { + $nestedConfig = $config . '.' . $nestedKeyAttributes['key_name'] . '$'; + } + + $nestedKeyName = $nestedKeyAttributes['key_name']; + $nestedLabel = $nestedKeyAttributes['label'] ?? $nestedKeyAttributes['key_name']; + $nestedContent[] = $this->createKey($nestedKeyName, $nestedLabel); + $nestedContent[] = $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( + 'The value of each nested key of the selected host dictionary variable for ' + . 'apply-for-rule is 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); + } + + /** + * Fetch custom variable information for the given variable name + * + * @param string $varName + * + * @return mixed + */ + 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); + } + + /** + * Get the expression for ordering the custom properties by value type. + * + * @param DbConnection $db + * @param array $types + * + * @return string + */ + 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 object, including session-added ones. + * + * @param string[] $addedVarUuids UUID strings of properties added this session + * @param string[] $requiredVarUuids UUID strings of properties marked required this session + * + * @return array + */ + protected function getObjectCustomProperties( + IcingaObject $object, + bool $isOverrideVars = false, + array $addedVarUuids = [], + array $requiredVarUuids = [] + ): 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', + 'required' => 'iop.required', + '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', 'iop.required']) + ->order($this->valueTypeOrderExpr($db, [ + 'string', + 'number', + 'bool', + 'datalist-strict', + 'datalist-non-strict', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary' + ])) + ->order('children') + ->order('key_name'); + + 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); + } + + $result = []; + 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']); + $row['allow_removal'] = $objectUuid === $row[$type . '_uuid']; + $row['required'] = ($row['required'] ?? 'n') === 'y'; + + if (isset($vars[$row['key_name']])) { + $row['value'] = $vars[$row['key_name']]; + } + + $result[$row['key_name']] = $row; + } + + if (! empty($addedVarUuids)) { + $uuidBytes = array_map( + fn($uuid) => Uuid::fromString($uuid)->getBytes(), + $addedVarUuids + ); + + $addedQuery = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label' + ] + ) + ->where('dp.uuid IN (?)', DbUtil::quoteBinaryCompat($uuidBytes, $db->getDbAdapter())); + + $addedRows = $db->getDbAdapter()->fetchAll($addedQuery, fetchMode: PDO::FETCH_ASSOC); + foreach ($addedRows as &$row) { + $row['uuid'] = DbUtil::binaryResult($row['uuid']); + } + + unset($row); + + $uuidBytes = array_flip($uuidBytes); + usort($addedRows, function ($a, $b) use ($uuidBytes) { + $posA = $uuidBytes[$a['uuid']] ?? PHP_INT_MAX; + $posB = $uuidBytes[$b['uuid']] ?? PHP_INT_MAX; + + return $posA <=> $posB; + }); + + foreach ($addedRows as $row) { + $row['allow_removal'] = true; + if (! isset($result[$row['key_name']])) { + $row['new'] = true; + } + + $row['required'] = in_array(Uuid::fromBytes($row['uuid'])->toString(), $requiredVarUuids, 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 +1579,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/Element/ArrayElement.php b/library/Director/Web/Form/Element/ArrayElement.php new file mode 100644 index 000000000..2646fbeb8 --- /dev/null +++ b/library/Director/Web/Form/Element/ArrayElement.php @@ -0,0 +1,120 @@ + 'array-input']; + + /** @var array Predefined values used for validation and term labels ['value' => 'label'] */ + private array $suggestedValues = []; + + /** + * Sets the placeholder text for the current instance. + * + * @param string $placeholder The placeholder text to set. + * + * @return $this + */ + public function setPlaceHolder(string $placeholder): static + { + $this->placeholder = $placeholder; + + return $this; + } + + protected function assemble(): void + { + 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; + }); + } + + /** + * Sets whether the form should auto-submit + * + * @param bool $shouldAutoSubmit Indicates if auto-submit should be enabled + * + * @return $this + */ + public function shouldAutoSubmit(bool $shouldAutoSubmit = true): static + { + $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); + } + + /** + * Sets the suggested values for the instance. + * + * @param array $suggestedValues An array of suggested values to set. + * + * @return $this + */ + public function setSuggestedValues(array $suggestedValues): static + { + $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..6706681f4 --- /dev/null +++ b/library/Director/Web/Form/Element/IplBoolean.php @@ -0,0 +1,66 @@ + $this->translate('Yes'), + 'n' => $this->translate('No'), + ]; + if (! $this->isRequired()) { + $options = [ + '' => $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'); + } + + 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/Element/SensitiveElement.php b/library/Director/Web/Form/Element/SensitiveElement.php new file mode 100644 index 000000000..f6dc7a7bd --- /dev/null +++ b/library/Director/Web/Form/Element/SensitiveElement.php @@ -0,0 +1,67 @@ +value === self::DUMMYPASSWORD; + } + + public function getValue() + { + return parent::getValue() ?? ''; + } + + /** + * Decide what to show in the rendered 'value' attribute + * + * PasswordElement's own masking logic doesn't work here. It needs more + * than one value candidate, or a flag saying the form was submitted, and + * our nested forms (Dictionary, DictionaryItem, NestedDictionary) never + * give it either one. + * + * DictionaryItem::prepare() already replaces a stored secret with the + * DUMMYPASSWORD placeholder before it gets here. So we only need one + * check: if the value is still that placeholder, show the placeholder. + * Otherwise show the real value, since it can only be empty or something + * the user just typed, never the old secret. + * + * We must not mask a freshly typed value either, or saving again would + * treat it as "unchanged" and quietly bring back the old secret. + */ + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback('value', function () { + if ($this->wasSubmittedUnchanged()) { + return self::DUMMYPASSWORD; + } + + return $this->getValue(); + }); + } +} diff --git a/library/Director/Web/ObjectPreview.php b/library/Director/Web/ObjectPreview.php index e4f7e50dd..fe0b94d68 100644 --- a/library/Director/Web/ObjectPreview.php +++ b/library/Director/Web/ObjectPreview.php @@ -44,7 +44,7 @@ public function renderTo(ControlsAndContent $cc) if ($params->shift('resolved')) { $object = $object::fromPlainObject( - $object->toPlainObject(true), + $object->toPlainObject(true, keepId: true), $object->getConnection() ); diff --git a/library/Director/Web/Table/IcingaHostAppliedServicesTable.php b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php index 415903b40..246eb173d 100644 --- a/library/Director/Web/Table/IcingaHostAppliedServicesTable.php +++ b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php @@ -30,6 +30,9 @@ class IcingaHostAppliedServicesTable extends SimpleQueryBasedTable private $allApplyRules; + /** @var bool Use deprecated links instead of the new links for the services linked to hosts */ + private $useDeprecatedLink = false; + /** * @param IcingaHost $host * @return static @@ -103,12 +106,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 +126,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..1ad374ef6 100644 --- a/library/Director/Web/Table/IcingaServiceSetServiceTable.php +++ b/library/Director/Web/Table/IcingaServiceSetServiceTable.php @@ -39,6 +39,9 @@ class IcingaServiceSetServiceTable extends ZfQueryBasedTable /** @var string|null */ protected $highlightedService; + /** @var bool Use deprecated links instead of the new links for the services linked to service sets */ + private $useDeprecatedLink = false; + /** * @param IcingaServiceSet $set * @return static @@ -101,6 +104,20 @@ public function highlightService($service) return $this; } + /** + * Whether to use deprecated links instead of the new links for the services + * + * @param bool $useDeprecatedLink + * + * @return $this + */ + public function useDeprecatedLink(bool $useDeprecatedLink = true): self + { + $this->useDeprecatedLink = $useDeprecatedLink; + + return $this; + } + /** * @param $row * @return BaseHtmlElement @@ -122,6 +139,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..667f870e3 100644 --- a/library/Director/Web/Table/ObjectsTableService.php +++ b/library/Director/Web/Table/ObjectsTableService.php @@ -20,6 +20,9 @@ class ObjectsTableService extends ObjectsTable protected $title; + /** @var bool Use deprecated links instead of the new links for the services */ + private $useDeprecatedLink = false; + /** @var IcingaHost */ protected $inheritedBy; @@ -61,6 +64,20 @@ public function setTitle($title) return $this; } + /** + * Whether to use deprecated links instead of new links. + * + * @param bool $useDeprecatedLink + * + * @return $this + */ + public function useDeprecatedLink(bool $useDeprecatedLink = true): self + { + $this->useDeprecatedLink = $useDeprecatedLink; + + return $this; + } + public function setHost(IcingaHost $host) { $this->host = $host; @@ -150,9 +167,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..a15e2b5f5 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 { @@ -94,11 +95,19 @@ protected function addTabsForExistingObject() )); } + if ($auth->hasPermission(Permission::ADMIN) && $this->hasCustomProperties()) { + $this->add('variables', array( + 'url' => sprintf('director/%s/variables', $type), + 'urlParams' => $params, + 'label' => $this->translate('Custom Variables') + )); + } + if ($auth->hasPermission(Permission::ADMIN) && $this->hasFields()) { $this->add('fields', array( 'url' => sprintf('director/%s/fields', $type), 'urlParams' => $params, - 'label' => $this->translate('Fields') + 'label' => $this->translate('Fields (Deprecated)') )); } @@ -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..ee807f812 --- /dev/null +++ b/library/Director/Web/Widget/CustomVarFieldsTable.php @@ -0,0 +1,62 @@ + '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(): void + { + 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([Text::create($property->label)])->setSeparator(' '), + static::td([Text::create($property->value_type)]), + ]; + + if (isset($property->used_count) && $property->used_count > 0) { + $columns[] = static::td([Text::create($this->translate('In use'))]); + } else { + $columns[] = static::td([Text::create($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..28d648f7c --- /dev/null +++ b/library/Director/Web/Widget/CustomVarRenderer.php @@ -0,0 +1,68 @@ +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..3f7c396b1 --- /dev/null +++ b/public/css/custom-var-fields-table.less @@ -0,0 +1,13 @@ +.common-table.custom-var-fields-table { + max-width: 100%; + + td { + vertical-align: middle; + // no horizontal padding, the row's own accent bar/indent handles the gutter + padding: 0.5em 0; + + &:first-child strong { + margin-left: 0.5em; + } + } +} 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..285776201 --- /dev/null +++ b/public/css/custom-variables-form.less @@ -0,0 +1,172 @@ +// Style +.custom-variables-form { + .btn-primary:disabled { + background-color: @gray-light; + border: none; + } + + .btn-discard:disabled { + background: @gray-light; + color: @disabled-gray; + border-color: transparent; + } + + .added-var-uuids:focus, .required-var-uuids:focus { + outline: none; + } + + .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; + } + + .control-group:has(> .item-required-checkbox) { + justify-content: end; + margin: 1em 0.5em 0 0; + } + + // 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 { + position: relative; + .remove-property { + position: absolute; + top: 0; + right: 0; + margin-top: -1em; + 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..a0083c573 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -17,6 +17,53 @@ table.common-table td { } } +.add-custom-var-button { + display: inline; +} + +.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; + overflow-x: auto; + max-width: 100%; + } +} + #layout.minimal-layout table.common-table td { padding-top: 0.5em; padding-bottom: 0.5em; @@ -490,6 +537,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 +996,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..d0f6323f2 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 || ! $actions.length) { 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_193.sql b/schema/mysql-migrations/upgrade_193.sql new file mode 100644 index 000000000..c997f5318 --- /dev/null +++ b/schema/mysql-migrations/upgrade_193.sql @@ -0,0 +1,220 @@ +CREATE TABLE director_property ( + uuid varbinary(16) NOT NULL, + parent_uuid varbinary(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', + 'sensitive', + '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 varbinary(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, + CONSTRAINT director_property_parent + FOREIGN KEY parent (parent_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +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=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_service_property ( + service_uuid varbinary(16) NOT NULL, + property_uuid varbinary(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 varbinary(16) NOT NULL, + property_uuid varbinary(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 varbinary(16) NOT NULL, + property_uuid varbinary(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 varbinary(16) NOT NULL, + property_uuid varbinary(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 varbinary(16) NOT NULL, + property_uuid varbinary(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; + +SET @stmt = (SELECT IF( + (SELECT EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = SCHEMA() + AND table_name = 'director_datalist' + AND index_name = 'uuid' + )), + 'SELECT 1', + 'ALTER TABLE director_datalist ADD UNIQUE KEY uuid (uuid)' +)); + +PREPARE stmt FROM @stmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; +SET @stmt = NULL; + +CREATE TABLE director_property_datalist ( + list_uuid varbinary(16) NOT NULL, + property_uuid varbinary(16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + UNIQUE KEY unique_property_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 + ON UPDATE 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_host_var + ADD CONSTRAINT icinga_host_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid); + +ALTER TABLE icinga_service_var + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; + +ALTER TABLE icinga_service_var + ADD CONSTRAINT icinga_service_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid); + +ALTER TABLE icinga_command_var + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; + +ALTER TABLE icinga_command_var + ADD CONSTRAINT icinga_command_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid); + +ALTER TABLE icinga_notification_var + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; + +ALTER TABLE icinga_notification_var + ADD CONSTRAINT icinga_notification_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid); + +ALTER TABLE icinga_service_set_var + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; + +ALTER TABLE icinga_service_set_var + ADD CONSTRAINT icinga_service_set_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid); + +ALTER TABLE icinga_user_var + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; + +ALTER TABLE icinga_user_var + ADD CONSTRAINT icinga_user_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid); + +INSERT INTO director_schema_migration +(schema_version, migration_time) +VALUES (193, NOW()); diff --git a/schema/mysql.sql b/schema/mysql.sql index 769a2428d..b8438d5fb 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -249,6 +249,59 @@ CREATE TABLE director_setting ( PRIMARY KEY(setting_name) ) 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, + value_type enum( + 'string', + 'number', + 'bool', + 'sensitive', + '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 DEFAULT NULL, + parent_uuid_v varbinary(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, + CONSTRAINT director_property_parent + FOREIGN KEY parent (parent_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +ALTER TABLE director_datalist + ADD UNIQUE KEY uuid (uuid); + +CREATE TABLE director_property_datalist ( + list_uuid varbinary(16) NOT NULL, + property_uuid varbinary(16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + UNIQUE KEY unique_property_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 + ON UPDATE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin; + CREATE TABLE icinga_zone ( id INT(10) UNSIGNED AUTO_INCREMENT NOT NULL, uuid VARBINARY(16) NOT NULL, @@ -461,6 +514,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), @@ -468,7 +522,10 @@ CREATE TABLE icinga_command_var ( FOREIGN KEY command (command_id) REFERENCES icinga_command (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_command_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE icinga_apiuser ( @@ -651,20 +708,41 @@ CREATE TABLE icinga_host_field ( 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=utf8mb4 COLLATE=utf8mb4_bin; + 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 +888,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), @@ -817,7 +896,10 @@ CREATE TABLE icinga_service_var ( FOREIGN KEY service (service_id) REFERENCES icinga_service (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE icinga_service_field ( @@ -901,6 +983,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), @@ -908,7 +991,10 @@ CREATE TABLE icinga_service_set_var ( FOREIGN KEY command (service_set_id) REFERENCES icinga_service_set (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_set_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE icinga_hostgroup ( @@ -1150,6 +1236,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), @@ -1157,7 +1244,10 @@ CREATE TABLE icinga_user_var ( FOREIGN KEY icinga_user (user_id) REFERENCES icinga_user (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_user_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE icinga_user_field ( @@ -1301,6 +1391,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), @@ -1308,7 +1399,10 @@ CREATE TABLE icinga_notification_var ( FOREIGN KEY notification (notification_id) REFERENCES icinga_notification (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_notification_var_property_uuid + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE icinga_notification_field ( @@ -2464,6 +2558,91 @@ CREATE TABLE icinga_usergroup_user_resolved ON UPDATE CASCADE ) ENGINE = InnoDB DEFAULT CHARSET = utf8; +CREATE TABLE icinga_service_property ( + service_uuid varbinary(16) NOT NULL, + property_uuid varbinary(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 varbinary(16) NOT NULL, + property_uuid varbinary(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 varbinary(16) NOT NULL, + property_uuid varbinary(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 varbinary(16) NOT NULL, + property_uuid varbinary(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 varbinary(16) NOT NULL, + property_uuid varbinary(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; + INSERT INTO director_schema_migration (schema_version, migration_time) - VALUES (192, NOW()); + VALUES (193, NOW()); diff --git a/schema/pgsql-migrations/upgrade_193.sql b/schema/pgsql-migrations/upgrade_193.sql new file mode 100644 index 000000000..0f7ca2322 --- /dev/null +++ b/schema/pgsql-migrations/upgrade_193.sql @@ -0,0 +1,232 @@ +CREATE TYPE enum_property_value_type AS ENUM( + 'string', + 'number', + 'bool', + 'sensitive', + '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 citext 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, + CONSTRAINT director_property_parent + FOREIGN KEY (parent_uuid) + REFERENCES director_property (uuid) + ON DELETE 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; + +-- Postgres does not auto-index an FK's referencing column; without this, deleting a +-- property does a sequential scan to find its children. +CREATE INDEX director_property_parent_uuid ON director_property (parent_uuid); + +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 INDEX icinga_host_property_property_uuid ON icinga_host_property (property_uuid); + +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 INDEX icinga_service_property_property_uuid ON icinga_service_property (property_uuid); + +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 INDEX icinga_command_property_property_uuid ON icinga_command_property (property_uuid); + +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 INDEX icinga_notification_property_property_uuid ON icinga_notification_property (property_uuid); + +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 INDEX icinga_service_set_property_property_uuid ON icinga_service_set_property (property_uuid); + +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 INDEX icinga_user_property_property_uuid ON icinga_user_property (property_uuid); + +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), + UNIQUE (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_host_var + ADD CONSTRAINT icinga_host_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid); + +ALTER TABLE icinga_service_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_service_var + ADD CONSTRAINT icinga_service_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid); + +ALTER TABLE icinga_command_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_command_var + ADD CONSTRAINT icinga_command_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid); + +ALTER TABLE icinga_notification_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_notification_var + ADD CONSTRAINT icinga_notification_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid); + +ALTER TABLE icinga_service_set_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_service_set_var + ADD CONSTRAINT icinga_service_set_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid); + +ALTER TABLE icinga_user_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_user_var + ADD CONSTRAINT icinga_user_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid); + +INSERT INTO director_schema_migration + (schema_version, migration_time) + VALUES (193, NOW()); diff --git a/schema/pgsql.sql b/schema/pgsql.sql index d4ca94b6a..abff207f5 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -17,6 +17,19 @@ 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', + 'sensitive', + '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 +259,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) @@ -318,6 +331,53 @@ 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 citext 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, + CONSTRAINT director_property_parent + FOREIGN KEY (parent_uuid) + REFERENCES director_property (uuid) + ON DELETE 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 INDEX director_property_parent_uuid ON director_property (parent_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), + UNIQUE (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, @@ -572,6 +632,24 @@ CREATE TABLE icinga_command_field ( 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 INDEX icinga_command_property_property_uuid ON icinga_command_property (property_uuid); CREATE TABLE icinga_command_var ( command_id integer NOT NULL, @@ -579,12 +657,16 @@ CREATE TABLE icinga_command_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 (command_id, varname), CONSTRAINT icinga_command_var_command FOREIGN KEY (command_id) REFERENCES icinga_command (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_command_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) ); CREATE INDEX command_var_command ON icinga_command_var (command_id); @@ -802,18 +884,41 @@ 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 INDEX icinga_host_property_property_uuid ON icinga_host_property (property_uuid); + 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) REFERENCES icinga_host (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) ); CREATE INDEX host_var_search_idx ON icinga_host_var (varname); @@ -975,18 +1080,42 @@ 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 INDEX icinga_service_property_property_uuid ON icinga_service_property (property_uuid); + + 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) REFERENCES icinga_service (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) ); CREATE INDEX service_var_search_idx ON icinga_service_var (varname); @@ -1088,18 +1217,42 @@ 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 INDEX icinga_service_set_property_property_uuid ON icinga_service_set_property (property_uuid); + + 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) REFERENCES icinga_service_set (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_set_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) ); CREATE INDEX service_set_var_service_set ON icinga_service_set_var (service_set_id); @@ -1370,12 +1523,16 @@ 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) REFERENCES icinga_user (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_user_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) ); CREATE INDEX user_var_search_idx ON icinga_user_var (varname); @@ -1407,6 +1564,26 @@ 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 INDEX icinga_user_property_property_uuid ON icinga_user_property (property_uuid); + + CREATE TABLE icinga_usergroup ( id serial, uuid bytea UNIQUE CHECK(LENGTH(uuid) = 16), @@ -1823,12 +2000,16 @@ 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) REFERENCES icinga_notification (id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_notification_var_property_uuid + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) ); CREATE INDEX notification_var_command ON icinga_notification_var (notification_id); @@ -1859,6 +2040,26 @@ 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 INDEX icinga_notification_property_property_uuid ON icinga_notification_property (property_uuid); + + CREATE TABLE icinga_notification_inheritance ( notification_id integer NOT NULL, parent_notification_id integer NOT NULL, @@ -2803,4 +3004,4 @@ CREATE INDEX usergroup_user_resolved_usergroup ON icinga_usergroup_user_resolved INSERT INTO director_schema_migration (schema_version, migration_time) - VALUES (192, NOW()); + VALUES (193, NOW()); diff --git a/test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php b/test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php new file mode 100644 index 000000000..a35c44511 --- /dev/null +++ b/test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php @@ -0,0 +1,131 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\CustomVariable; + +use Icinga\Module\Director\CustomVariable\CustomVariables; +use PHPUnit\Framework\TestCase; + +/** + * Unit tests for CustomVariables rendering across the types used in real datacenter monitoring. + * + * Scenario: a host template for Linux servers carrying environment strings, check counts, + * feature flags, HTTP vhost lists, disk threshold maps, and check-interval overrides. + */ +class CustomVariableDictionaryRenderingTest extends TestCase +{ + protected string $indent = ' '; + + public function testStringRendersSingleLine(): void + { + $vars = new CustomVariables(); + $vars->env = 'production'; + + $this->assertEquals( + $this->indent . 'vars.env = "production"' . "\n", + $vars->toConfigString() + ); + } + + public function testNumberRendersWithoutQuotes(): void + { + $vars = new CustomVariables(); + $vars->max_check_attempts = 3; + + $this->assertEquals( + $this->indent . 'vars.max_check_attempts = 3' . "\n", + $vars->toConfigString() + ); + } + + public function testBooleanRendersAsKeyword(): void + { + $vars = new CustomVariables(); + $vars->notifications_enabled = false; + + $this->assertEquals( + $this->indent . 'vars.notifications_enabled = false' . "\n", + $vars->toConfigString() + ); + } + + public function testArrayRendersAsBracketList(): void + { + $vars = new CustomVariables(); + $vars->http_vhosts = ['web01.example.com', 'web02.example.com']; + + $this->assertEquals( + $this->indent . 'vars.http_vhosts = [ "web01.example.com", "web02.example.com" ]' . "\n", + $vars->toConfigString() + ); + } + + public function testFixedDictionaryRendersAsBlock(): void + { + $vars = new CustomVariables(); + $vars->disk_thresholds = ['warn' => '10%', 'crit' => '5%']; + + // Dictionary keys are sorted; crit before warn + $expected = $this->indent . 'vars.disk_thresholds = {' . "\n" + . $this->indent . $this->indent . 'crit = "5%"' . "\n" + . $this->indent . $this->indent . 'warn = "10%"' . "\n" + . $this->indent . '}' . "\n"; + + $this->assertEquals($expected, $vars->toConfigString()); + } + + public function testDynamicDictionaryRendersWithPlusEquals(): void + { + $vars = new CustomVariables(); + $vars->disk_checks = ['warn' => '20%', 'crit' => '10%']; + $vars->setOverrideKeyName('disk_checks'); + + // += operator used when the key matches the override key name + $expected = $this->indent . 'vars.disk_checks += {' . "\n" + . $this->indent . $this->indent . 'crit = "10%"' . "\n" + . $this->indent . $this->indent . 'warn = "20%"' . "\n" + . $this->indent . '}' . "\n"; + + $this->assertEquals($expected, $vars->toConfigString()); + } + + public function testSpecialKeyNameUsesArraySyntax(): void + { + $vars = new CustomVariables(); + $vars->{'check-interval'} = 60; + + $this->assertEquals( + $this->indent . 'vars["check-interval"] = 60' . "\n", + $vars->toConfigString() + ); + } + + public function testApplyForWhitelistAllowsValueMacro(): void + { + $vars = new CustomVariables(); + $vars->setWhiteList(['value.path']); + $vars->url = '$value.path$'; + + // Whitelisted macro rendered as an unquoted Icinga 2 expression reference + $this->assertEquals( + $this->indent . 'vars.url = value.path' . "\n", + $vars->toConfigString(true) + ); + } + + public function testApplyForWhitelistStripsUnknownValueMacro(): void + { + $vars = new CustomVariables(); + $vars->setWhiteList(['value.path']); + $vars->url = '$value.not_a_field$'; + + // Unknown macro is not in the whitelist: rendered as a quoted string with the + // macro syntax preserved rather than emitted as an unquoted expression + $this->assertEquals( + $this->indent . 'vars.url = "$value.not_a_field$"' . "\n", + $vars->toConfigString(true) + ); + } +} diff --git a/test/php/library/Director/CustomVariable/CustomVariableTest.php b/test/php/library/Director/CustomVariable/CustomVariableTest.php new file mode 100644 index 000000000..7dae982f3 --- /dev/null +++ b/test/php/library/Director/CustomVariable/CustomVariableTest.php @@ -0,0 +1,332 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\CustomVariable; + +use InvalidArgumentException; +use Icinga\Module\Director\CustomVariable\CustomVariable; +use Icinga\Module\Director\CustomVariable\CustomVariableArray; +use Icinga\Module\Director\CustomVariable\CustomVariableBoolean; +use Icinga\Module\Director\CustomVariable\CustomVariableDictionary; +use Icinga\Module\Director\CustomVariable\CustomVariableNull; +use Icinga\Module\Director\CustomVariable\CustomVariableNumber; +use Icinga\Module\Director\CustomVariable\CustomVariableString; +use PHPUnit\Framework\TestCase; +use Ramsey\Uuid\Uuid; + +class CustomVariableTest extends TestCase +{ + // ------------------------------------------------------------------------- + // CustomVariable::create() — type dispatch + // ------------------------------------------------------------------------- + + public function testCreateNullReturnsNull(): void + { + $this->assertInstanceOf(CustomVariableNull::class, CustomVariable::create('notes', null)); + } + + public function testCreateBoolTrueReturnsBoolean(): void + { + $this->assertInstanceOf( + CustomVariableBoolean::class, + CustomVariable::create('notifications_enabled', true) + ); + } + + public function testCreateBoolFalseReturnsBoolean(): void + { + $this->assertInstanceOf( + CustomVariableBoolean::class, + CustomVariable::create('notifications_enabled', false) + ); + } + + public function testCreateIntegerReturnsNumber(): void + { + $this->assertInstanceOf(CustomVariableNumber::class, CustomVariable::create('max_check_attempts', 3)); + } + + public function testCreateFloatReturnsNumber(): void + { + $this->assertInstanceOf(CustomVariableNumber::class, CustomVariable::create('load_threshold', 3.14)); + } + + public function testCreateStringReturnsString(): void + { + $this->assertInstanceOf(CustomVariableString::class, CustomVariable::create('env', 'production')); + } + + public function testCreateIndexedArrayReturnsArray(): void + { + $this->assertInstanceOf( + CustomVariableArray::class, + CustomVariable::create('dns_servers', ['8.8.8.8', '8.8.4.4', '1.1.1.1']) + ); + } + + public function testCreateEmptyArrayReturnsArray(): void + { + $this->assertInstanceOf(CustomVariableArray::class, CustomVariable::create('excluded_checks', [])); + } + + public function testCreateAssociativeArrayReturnsDictionary(): void + { + $this->assertInstanceOf( + CustomVariableDictionary::class, + CustomVariable::create('disk_thresholds', ['warn' => '20%']) + ); + } + + public function testCreateMixedKeyArrayReturnsDictionary(): void + { + // Mixed numeric and string keys → dictionary because at least one key is non-integer + $this->assertInstanceOf( + CustomVariableDictionary::class, + CustomVariable::create('interfaces', [0 => 'eth0', 'label' => 'Primary Interface']) + ); + } + + public function testCreateObjectReturnsDictionary(): void + { + $obj = (object) ['warn' => '20%', 'crit' => '10%']; + $this->assertInstanceOf(CustomVariableDictionary::class, CustomVariable::create('disk_thresholds', $obj)); + } + + public function testCreatePreservesKey(): void + { + $var = CustomVariable::create('environment', 'production'); + $this->assertEquals('environment', $var->getKey()); + } + + public function testCreatePreservesValue(): void + { + $var = CustomVariable::create('env', 'production'); + $this->assertEquals('production', $var->getValue()); + } + + // ------------------------------------------------------------------------- + // CustomVariable::fromDbRow() — format dispatch and field hydration + // ------------------------------------------------------------------------- + + public function testFromDbRowStringFormatCreatesString(): void + { + $row = (object) [ + 'format' => 'string', + 'varname' => 'env', + 'varvalue' => 'production', + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertInstanceOf(CustomVariableString::class, $var); + $this->assertEquals('production', $var->getValue()); + } + + public function testFromDbRowJsonStringCreatesString(): void + { + $row = (object) [ + 'format' => 'json', + 'varname' => 'env', + 'varvalue' => json_encode('production'), + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertInstanceOf(CustomVariableString::class, $var); + $this->assertEquals('production', $var->getValue()); + } + + public function testFromDbRowJsonArrayCreatesArray(): void + { + $row = (object) [ + 'format' => 'json', + 'varname' => 'vhosts', + 'varvalue' => json_encode(['web01', 'web02']), + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertInstanceOf(CustomVariableArray::class, $var); + } + + public function testFromDbRowJsonObjectCreatesDictionary(): void + { + $row = (object) [ + 'format' => 'json', + 'varname' => 'disk_thresholds', + 'varvalue' => json_encode(['warn' => '20%', 'crit' => '10%']), + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertInstanceOf(CustomVariableDictionary::class, $var); + } + + public function testFromDbRowExpressionThrows(): void + { + $this->expectException(InvalidArgumentException::class); + + CustomVariable::fromDbRow((object) [ + 'format' => 'expression', + 'varname' => 'expr', + 'varvalue' => '{{ 1 + 1 }}', + ]); + } + + public function testFromDbRowUnknownFormatThrows(): void + { + $this->expectException(InvalidArgumentException::class); + + CustomVariable::fromDbRow((object) [ + 'format' => 'binary', + 'varname' => 'snmp_community', + 'varvalue' => 'cGFzc3dvcmQ=', + ]); + } + + public function testFromDbRowSetsChecksumWhenPresent(): void + { + $checksum = sha1('test', true); + $row = (object) [ + 'format' => 'string', + 'varname' => 'env', + 'varvalue' => 'prod', + 'checksum' => $checksum, + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertEquals($checksum, $var->getChecksum()); + } + + public function testFromDbRowDoesNotSetChecksumWhenAbsent(): void + { + $row = (object) [ + 'format' => 'string', + 'varname' => 'env', + 'varvalue' => 'prod', + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertNull($var->getChecksum()); + } + + public function testFromDbRowSetsUuidWhenPropertyUuidPresent(): void + { + $uuid = Uuid::uuid4(); + $row = (object) [ + 'format' => 'string', + 'varname' => 'env', + 'varvalue' => 'prod', + 'property_uuid' => $uuid->getBytes(), + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertNotNull($var->getUuid()); + $this->assertEquals($uuid->toString(), $var->getUuid()->toString()); + } + + public function testFromDbRowDoesNotSetUuidWhenPropertyUuidAbsent(): void + { + $row = (object) [ + 'format' => 'string', + 'varname' => 'env', + 'varvalue' => 'prod', + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertNull($var->getUuid()); + } + + public function testFromDbRowDoesNotSetUuidWhenPropertyUuidNull(): void + { + $row = (object) [ + 'format' => 'string', + 'varname' => 'env', + 'varvalue' => 'prod', + 'property_uuid' => null, + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertNull($var->getUuid()); + } + + public function testFromDbRowMarksVarAsLoadedFromDb(): void + { + $row = (object) [ + 'format' => 'string', + 'varname' => 'env', + 'varvalue' => 'prod', + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertFalse($var->isNew()); + } + + public function testFromDbRowMarksVarAsUnmodified(): void + { + $row = (object) [ + 'format' => 'string', + 'varname' => 'env', + 'varvalue' => 'prod', + ]; + + $var = CustomVariable::fromDbRow($row); + $this->assertFalse($var->hasBeenModified()); + } + + // ------------------------------------------------------------------------- + // CustomVariableDictionary::equals() + // ------------------------------------------------------------------------- + + public function testEqualDictionariesAreEqual(): void + { + $a = CustomVariable::create('disk_thresholds', ['warn' => '20%', 'crit' => '10%']); + $b = CustomVariable::create('disk_thresholds', ['warn' => '20%', 'crit' => '10%']); + $this->assertTrue($a->equals($b)); + } + + public function testDictionaryKeyOrderDoesNotMatter(): void + { + // Values must match regardless of the insertion order of keys + $a = CustomVariable::create('disk_thresholds', ['crit' => '10%', 'warn' => '20%']); + $b = CustomVariable::create('disk_thresholds', ['warn' => '20%', 'crit' => '10%']); + $this->assertTrue($a->equals($b)); + } + + public function testGetDbValueSerializesKeysInSortedOrder(): void + { + $dict = CustomVariable::create('disk_thresholds', ['warn' => '20%', 'crit' => '10%']); + assert($dict instanceof CustomVariableDictionary); + + $this->assertSame('{"crit":"10%","warn":"20%"}', $dict->getDbValue()); + } + + public function testDictionariesWithDifferentKeysAreNotEqual(): void + { + $a = CustomVariable::create('disk_thresholds', ['warn' => '20%', 'crit' => '10%']); + $b = CustomVariable::create('disk_thresholds', ['warn' => '20%']); + $this->assertFalse($a->equals($b)); + } + + public function testDictionariesWithSameKeysDifferentValuesAreNotEqual(): void + { + $a = CustomVariable::create('disk_thresholds', ['warn' => '20%', 'crit' => '10%']); + $b = CustomVariable::create('disk_thresholds', ['warn' => '30%', 'crit' => '10%']); + $this->assertFalse($a->equals($b)); + } + + public function testDictionaryIsNotEqualToString(): void + { + $dict = CustomVariable::create('disk_thresholds', ['warn' => '20%']); + $str = CustomVariable::create('env', 'production'); + $this->assertFalse($dict->equals($str)); + } + + public function testEmptyArrayVarsAreEqual(): void + { + // Empty PHP arrays have no string keys so CustomVariable::create() returns + // CustomVariableArray, not CustomVariableDictionary — both produce the same + // db value '[]', so they must compare equal. + $a = CustomVariable::create('disk_thresholds', []); + $b = CustomVariable::create('disk_thresholds', []); + $this->assertTrue($a->equals($b)); + } +} diff --git a/test/php/library/Director/CustomVariable/CustomVariableValueCleanerTest.php b/test/php/library/Director/CustomVariable/CustomVariableValueCleanerTest.php new file mode 100644 index 000000000..74e16b881 --- /dev/null +++ b/test/php/library/Director/CustomVariable/CustomVariableValueCleanerTest.php @@ -0,0 +1,134 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\CustomVariable; + +use Icinga\Module\Director\CustomVariable\CustomVariableValueCleaner; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Objects\DirectorProperty; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Test\BaseTestCase; +use Ramsey\Uuid\Uuid; + +class CustomVariableValueCleanerTest extends BaseTestCase +{ + private const PREFIX = '___TEST___'; + + private const ROOT_KEY_NAME = self::PREFIX . 'contact_ips'; + + public function testKeepPropertyInPlaceNullsFixedArraySlotWithoutReindexing(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $host = IcingaHost::create([ + 'object_name' => self::PREFIX . 'retype_host', + 'object_type' => 'object', + 'address' => '192.0.2.70', + ], $db); + $host->store(); + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => self::ROOT_KEY_NAME, + 'value_type' => 'fixed-array', + 'label' => 'Contact IPs', + ], $db)->store(); + + $itemUuids = []; + foreach (['0', '1', '2'] as $keyName) { + $itemUuid = Uuid::uuid4(); + $itemUuids[$keyName] = $itemUuid; + DirectorProperty::create([ + 'uuid' => $itemUuid->getBytes(), + 'key_name' => $keyName, + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + } + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => self::ROOT_KEY_NAME, + 'varvalue' => json_encode(['10.0.0.1', '10.0.0.2', '10.0.0.3']), + 'format' => 'json', + 'property_uuid' => DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba), + ]); + + $cleaner = new CustomVariableValueCleaner($db); + $cleaner->removeObjectCustomVars( + [ + 'key_name' => '1', + 'uuid' => $itemUuids['1']->getBytes(), + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'string', + 'label' => null, + 'description' => null, + ], + [ + 'key_name' => self::ROOT_KEY_NAME, + 'uuid' => $rootUuid->getBytes(), + 'parent_uuid' => null, + 'value_type' => 'fixed-array', + 'label' => 'Contact IPs', + 'description' => null, + ], + true + ); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', self::ROOT_KEY_NAME) + ); + + $this->assertEquals( + '["10.0.0.1",null,"10.0.0.3"]', + $updatedValue, + 'a retyped-in-place item must be nulled out at its own index, not unset and reindexed' + ); + + $siblingKeyNames = $dba->fetchCol( + $dba->select()->from('director_property', ['key_name']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba)) + ->order('key_name') + ); + + $this->assertEquals( + ['0', '1', '2'], + $siblingKeyNames, + 'siblings must keep their original key_name, retyping in place must not trigger a reindex' + ); + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $dba->delete('icinga_host', ['object_name = ?' => self::PREFIX . 'retype_host']); + + $rows = $dba->fetchAll( + $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', self::ROOT_KEY_NAME) + ); + foreach ($rows as $row) { + $rootUuid = DbUtil::binaryResult($row->uuid); + $dba->delete( + 'director_property', + $dba->quoteInto('parent_uuid = ?', DbUtil::quoteBinaryCompat($rootUuid, $dba)) + ); + } + $dba->delete('director_property', $dba->quoteInto('key_name = ?', self::ROOT_KEY_NAME)); + } + + parent::tearDown(); + } +} diff --git a/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php b/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php new file mode 100644 index 000000000..3f2daf135 --- /dev/null +++ b/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php @@ -0,0 +1,217 @@ +expectException(InvalidArgumentException::class); + CustomVariableValueValidator::assertMatchesType('env', ['staging', 'production'], 'string'); + } + + public function testStringValueAcceptsNumericString(): void + { + CustomVariableValueValidator::assertMatchesType('timeout', '30', 'number'); + $this->addToAssertionCount(1); + } + + public function testArrayValueRejectsDictionary(): void + { + $this->expectException(InvalidArgumentException::class); + CustomVariableValueValidator::assertMatchesType( + 'ssh_args', + (object) ['StrictHostKeyChecking' => 'no'], + 'dynamic-array' + ); + } + + public function testArrayValueAcceptsList(): void + { + CustomVariableValueValidator::assertMatchesType('ssh_args', ['-4', '-C'], 'fixed-array'); + $this->addToAssertionCount(1); + } + + public function testDictionaryValueRejectsList(): void + { + $this->expectException(InvalidArgumentException::class); + CustomVariableValueValidator::assertMatchesType('mysql', ['3306', 'root'], 'dynamic-dictionary'); + } + + public function testDictionaryValueAcceptsObject(): void + { + CustomVariableValueValidator::assertMatchesType( + 'mysql', + (object) ['host' => 'db01.example.com'], + 'fixed-dictionary' + ); + $this->addToAssertionCount(1); + } + + public function testDatalistTypeAcceptsScalarValue(): void + { + CustomVariableValueValidator::assertMatchesType('env_choice', 'prod', 'datalist-strict'); + $this->addToAssertionCount(1); + } + + public function testDatalistTypeAcceptsArrayOfScalars(): void + { + CustomVariableValueValidator::assertMatchesType('env_choices', ['prod', 'dev'], 'datalist-non-strict'); + $this->addToAssertionCount(1); + } + + public function testDatalistTypeRejectsDictionary(): void + { + $this->expectException(InvalidArgumentException::class); + CustomVariableValueValidator::assertMatchesType('env_choice', (object) ['a' => 'b'], 'datalist-strict'); + } + + public function testDatalistStrictRejectsUnknownValue(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $property = $this->makeDatalistStrictProperty( + $db, + 'validator_env_choice', + 'validator_env', + ['prod', 'dev'] + ); + + $this->expectException(InvalidArgumentException::class); + CustomVariableValueValidator::assertDatalistValueAllowed( + 'validator_env_choice', + 'staging', + Uuid::fromBytes($property->get('uuid')), + $db + ); + } + + public function testDatalistStrictAcceptsKnownValue(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $property = $this->makeDatalistStrictProperty( + $db, + 'validator_env_choice_ok', + 'validator_env_ok', + ['prod'] + ); + + CustomVariableValueValidator::assertDatalistValueAllowed( + 'validator_env_choice_ok', + 'prod', + Uuid::fromBytes($property->get('uuid')), + $db + ); + $this->addToAssertionCount(1); + } + + public function testDatalistStrictAcceptsArrayOfKnownValues(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $property = $this->makeDatalistStrictProperty( + $db, + 'validator_env_choices_array', + 'validator_env_array', + ['prod', 'dev'] + ); + + CustomVariableValueValidator::assertDatalistValueAllowed( + 'validator_env_choices_array', + ['prod', 'dev'], + Uuid::fromBytes($property->get('uuid')), + $db + ); + $this->addToAssertionCount(1); + } + + public function testDatalistStrictRejectsArrayContainingAnUnknownValue(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $property = $this->makeDatalistStrictProperty( + $db, + 'validator_env_choices_bad_array', + 'validator_env_bad_array', + ['prod', 'dev'] + ); + + $this->expectException(InvalidArgumentException::class); + CustomVariableValueValidator::assertDatalistValueAllowed( + 'validator_env_choices_bad_array', + ['prod', 'staging'], + Uuid::fromBytes($property->get('uuid')), + $db + ); + } + + /** + * @param string[] $entryNames + */ + private function makeDatalistStrictProperty( + $db, + string $keyName, + string $listName, + array $entryNames + ): DirectorProperty { + $listName = self::PREFIX . $listName; + $keyName = self::PREFIX . $keyName; + + $datalist = DirectorDatalist::create(['list_name' => $listName, 'owner' => 'test'], $db); + $datalist->setEntries(array_map( + fn ($name) => (object) ['entry_name' => $name, 'entry_value' => ucfirst($name)], + $entryNames + )); + $datalist->store(); + + $plain = (object) [ + 'uuid' => Uuid::uuid4()->toString(), + 'key_name' => $keyName, + 'value_type' => 'datalist-strict', + 'label' => 'Environment', + 'parent_uuid' => null, + 'category' => null, + 'description' => null, + 'datalist' => $listName, + 'items' => [], + ]; + $property = DirectorProperty::import($plain, $db); + $property->store(); + + return $property; + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + $dba->delete('director_property', $dba->quoteInto('key_name LIKE ?', self::PREFIX . 'validator_%')); + $dba->delete('director_datalist', $dba->quoteInto('list_name LIKE ?', self::PREFIX . 'validator_%')); + } + + parent::tearDown(); + } +} diff --git a/test/php/library/Director/CustomVariable/CustomVariablesTest.php b/test/php/library/Director/CustomVariable/CustomVariablesTest.php index c5ba9f67f..36f5eb81e 100644 --- a/test/php/library/Director/CustomVariable/CustomVariablesTest.php +++ b/test/php/library/Director/CustomVariable/CustomVariablesTest.php @@ -1,5 +1,8 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + namespace Tests\Icinga\Module\Director\CustomVariable; use Icinga\Module\Director\CustomVariable\CustomVariables; diff --git a/test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php b/test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php new file mode 100644 index 000000000..af42d5d61 --- /dev/null +++ b/test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php @@ -0,0 +1,107 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\DirectorObject\Automation; + +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\DirectorObject\Automation\Basket; +use Icinga\Module\Director\DirectorObject\Automation\BasketDiff; +use Icinga\Module\Director\DirectorObject\Automation\BasketSnapshot; +use Icinga\Module\Director\Objects\DirectorProperty; +use Icinga\Module\Director\Test\BaseTestCase; +use Ramsey\Uuid\Uuid; + +class BasketDiffTest extends BaseTestCase +{ + private const PREFIX = '___TEST___'; + + public function testDiffReportsUnchangedWhenNothingChanged(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => self::PREFIX . 'snmp_settings', + 'value_type' => 'fixed-dictionary', + 'label' => 'SNMP Settings', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'community', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $propertyUuidString = $rootUuid->toString(); + $exportedProperty = DirectorProperty::loadWithUniqueId($rootUuid, $db)->export(); + $basket = Basket::create(['uuid' => Uuid::uuid4()->getBytes(), 'basket_name' => self::PREFIX . 'basket2']); + $snapshot = BasketSnapshot::forBasketFromJson( + $basket, + json_encode(['CustomVariable' => [$propertyUuidString => $exportedProperty]]) + ); + + $diff = new BasketDiff($snapshot, $db); + + $this->assertFalse( + $diff->hasChangedFor('CustomVariable', $propertyUuidString, $rootUuid), + 'the diff must not report a change when nothing was actually modified' + ); + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + foreach ([self::PREFIX . 'snmp_settings'] as $keyName) { + $rows = $dba->fetchAll( + $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', $keyName) + ); + foreach ($rows as $row) { + $this->deletePropertyTree($dba, DbUtil::binaryResult($row->uuid)); + } + } + } + + parent::tearDown(); + } + + /** + * Delete a director_property row along with all of its descendants, however deep the + * nesting goes. + * + * @return void + */ + private function deletePropertyTree($dba, string $uuid): void + { + $childUuids = array_map( + [DbUtil::class, 'binaryResult'], + $dba->fetchCol( + $dba->select()->from('director_property', ['uuid'])->where( + 'parent_uuid = ?', + DbUtil::quoteBinaryCompat($uuid, $dba) + ) + ) + ); + foreach ($childUuids as $childUuid) { + $this->deletePropertyTree($dba, $childUuid); + } + $dba->delete( + 'director_property_datalist', + $dba->quoteInto('property_uuid = ?', DbUtil::quoteBinaryCompat($uuid, $dba)) + ); + $dba->delete( + 'director_property', + $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($uuid, $dba)) + ); + } +} diff --git a/test/php/library/Director/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php new file mode 100644 index 000000000..f9cac4eef --- /dev/null +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -0,0 +1,566 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form; + +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Forms\CustomVariableForm; +use Icinga\Module\Director\Objects\DirectorDatalist; +use Icinga\Module\Director\Objects\DirectorProperty; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Test\BaseTestCase; +use Ramsey\Uuid\Uuid; +use Tests\Icinga\Module\Director\Form\Lib\TestableCustomVariableForm; + +class CustomVariableFormTest extends BaseTestCase +{ + /** @var string[] Key names created during tests, for tearDown cleanup */ + private array $createdKeyNames = []; + + /** @var string[] Datalist names created during tests, for tearDown cleanup */ + private array $createdDatalistNames = []; + + /** @var string[] Host names created during tests, for tearDown cleanup */ + private array $createdHostNames = []; + + public function testAddStringPropertyCreatesRow(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $form = new TestableCustomVariableForm($db); + $form->setTestValues([ + 'key_name' => '___TEST___environment', + 'value_type' => 'string', + 'label' => 'Environment Tag', + 'description' => 'Deployment environment: production, staging, or dev', + ]); + $this->createdKeyNames[] = '___TEST___environment'; + + self::callMethod($form, 'onSuccess', []); + + $dba = $db->getDbAdapter(); + $row = $dba->fetchRow( + $dba->select() + ->from('director_property', ['key_name', 'value_type']) + ->where('key_name = ?', '___TEST___environment') + ); + + $this->assertNotFalse($row, 'director_property row should be created'); + $this->assertSame('string', $row->value_type); + $this->assertNotNull($form->getUUid(), 'form UUID should be set after creation'); + } + + public function testAddDynamicArrayPropertyCreatesParentAndChildRows(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $form = new TestableCustomVariableForm($db); + $form->setTestValues([ + 'key_name' => '___TEST___contact_groups', + 'value_type' => 'dynamic-array', + 'item_type' => 'string', + 'label' => 'Contact Groups', + 'description' => 'Teams that receive alerts for this host (e.g. noc, linux-ops)', + ]); + $this->createdKeyNames[] = '___TEST___contact_groups'; + + self::callMethod($form, 'onSuccess', []); + + $dba = $db->getDbAdapter(); + $parentRow = $dba->fetchRow( + $dba->select() + ->from('director_property', ['value_type']) + ->where('key_name = ?', '___TEST___contact_groups') + ); + $this->assertNotFalse($parentRow, 'parent director_property row should be created'); + $this->assertSame('dynamic-array', $parentRow->value_type); + + $parentUuid = $form->getUUid(); + $this->assertNotNull($parentUuid, 'form UUID should be set after creation'); + $childRows = $dba->fetchAll( + $dba->select() + ->from('director_property', ['key_name', 'value_type']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $dba)) + ); + $this->assertCount(1, $childRows, 'exactly one child row should be created for the item type'); + $this->assertSame('0', (string) $childRows[0]->key_name); + $this->assertSame('string', $childRows[0]->value_type); + } + + public function testAssembleOffersContainerTypesOnlyAtTheTopLevel(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // The form's persistence bypasses DirectorProperty::store() (see addNewProperty()/ + // updateExistingProperty(), both raw $db->insert()/update()), so this dropdown is the + // only thing standing between a submitted request and an illegal nested container row. + // Zend's DeferredInArrayValidator on the select rejects any value_type not present in + // these options, so what's offered here is a real enforcement boundary, not cosmetic. + $db = $this->getDb(); + + $rootForm = new CustomVariableForm($db); + self::callMethod($rootForm, 'assemble', []); + $rootOptions = $rootForm->getElement('value_type'); + $this->assertNotNull($rootOptions->getOption('fixed-array')); + $this->assertNotNull($rootOptions->getOption('fixed-dictionary')); + $this->assertNotNull($rootOptions->getOption('dynamic-dictionary')); + $this->assertNotNull($rootOptions->getOption('dynamic-array')); + + $fieldForm = new CustomVariableForm($db, null, true, Uuid::uuid4()); + self::callMethod($fieldForm, 'assemble', []); + $fieldOptions = $fieldForm->getElement('value_type'); + $this->assertNull( + $fieldOptions->getOption('fixed-array'), + 'fixed-array must not be offered as a nested field' + ); + $this->assertNull( + $fieldOptions->getOption('fixed-dictionary'), + 'fixed-dictionary must not be offered as a nested field' + ); + $this->assertNull( + $fieldOptions->getOption('dynamic-dictionary'), + 'dynamic-dictionary must not be offered as a nested field' + ); + $this->assertNotNull( + $fieldOptions->getOption('dynamic-array'), + 'dynamic-array must still be offered as a nested field' + ); + } + + public function testUpdateStringPropertyKeyName(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + $createForm = new TestableCustomVariableForm($db); + $createForm->setTestValues([ + 'key_name' => '___TEST___http_uri', + 'value_type' => 'string', + 'label' => 'HTTP URI', + 'description' => 'URI path to probe, e.g. /api/health', + ]); + $this->createdKeyNames[] = '___TEST___http_uri'; + $this->createdKeyNames[] = '___TEST___http_url'; + self::callMethod($createForm, 'onSuccess', []); + $uuid = $createForm->getUUid(); + + $updateForm = new TestableCustomVariableForm($db, $uuid); + $updateForm->setTestValues([ + 'key_name' => '___TEST___http_url', + 'value_type' => 'string', + 'label' => 'HTTP URL', + 'description' => 'URI path to probe, e.g. /api/health', + ]); + self::callMethod($updateForm, 'onSuccess', []); + + $dba = $db->getDbAdapter(); + $renamedRow = $dba->fetchRow( + $dba->select() + ->from('director_property', ['key_name']) + ->where('key_name = ?', '___TEST___http_url') + ); + $this->assertNotFalse($renamedRow, 'renamed director_property row should exist'); + + $oldRow = $dba->fetchRow( + $dba->select() + ->from('director_property', ['key_name']) + ->where('key_name = ?', '___TEST___http_uri') + ); + $this->assertFalse($oldRow, 'original key_name should not exist after rename'); + } + + public function testFailedCreateDoesNotLeaveTransactionOpen(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $form = new TestableCustomVariableForm($db); + $form->setTestValues([ + 'key_name' => '___TEST___ssh_port', + 'value_type' => 'number', + 'label' => 'SSH Port', + 'description' => 'TCP port the SSH daemon listens on', + ]); + $this->createdKeyNames[] = '___TEST___ssh_port'; + self::callMethod($form, 'onSuccess', []); + + // A second admin, unaware the property already exists, tries to create the same one. + $secondForm = new TestableCustomVariableForm($db); + $secondForm->setTestValues([ + 'key_name' => '___TEST___ssh_port', + 'value_type' => 'number', + 'label' => 'SSH Port (custom)', + 'description' => 'Port used for SSH health checks', + ]); + + $thrown = false; + try { + self::callMethod($secondForm, 'onSuccess', []); + } catch (\Throwable $e) { + $thrown = true; + } + $this->assertTrue($thrown, 'creating a second property with the same key_name must raise an exception'); + + // The transaction opened by the failed onSuccess() call must not be left open. On + // PostgreSQL, an unhandled exception inside beginTransaction() without a matching + // rollBack() aborts the whole connection, so the very next query on it fails with + // "current transaction is aborted" instead of running normally. + $count = $dba->fetchOne( + $dba->select() + ->from('director_property', ['cnt' => 'COUNT(*)']) + ->where('key_name = ?', '___TEST___ssh_port') + ); + $this->assertSame('1', (string) $count, 'exactly one row must exist, the failed insert must not linger'); + } + + public function testUpdateDatalistPropertyRelinksToNewlySelectedDatalist(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $listA = DirectorDatalist::create([ + 'list_name' => '___TEST___tier_a', + 'owner' => 'test', + ], $db); + $listA->store(); + $this->createdDatalistNames[] = '___TEST___tier_a'; + + $listB = DirectorDatalist::create([ + 'list_name' => '___TEST___tier_b', + 'owner' => 'test', + ], $db); + $listB->store(); + $this->createdDatalistNames[] = '___TEST___tier_b'; + + $createForm = new TestableCustomVariableForm($db); + $createForm->setTestValues([ + 'key_name' => '___TEST___escalation_tier', + 'value_type' => 'datalist-strict', + 'item_type' => '', + 'list' => $listA->get('id'), + 'label' => 'Escalation Tier', + 'description' => 'Severity tier used to page the on-call rotation', + ]); + $this->createdKeyNames[] = '___TEST___escalation_tier'; + self::callMethod($createForm, 'onSuccess', []); + $uuid = $createForm->getUUid(); + + $linkedListUuid = $dba->fetchOne( + $dba->select() + ->from('director_property_datalist', ['list_uuid']) + ->where('property_uuid = ?', DbUtil::quoteBinaryCompat($uuid->getBytes(), $dba)) + ); + $this->assertSame( + DbUtil::binaryResult($listA->get('uuid')), + DbUtil::binaryResult($linkedListUuid), + 'property should initially be linked to the datalist selected on creation' + ); + + $updateForm = new TestableCustomVariableForm($db, $uuid); + $updateForm->setTestValues([ + 'key_name' => '___TEST___escalation_tier', + 'value_type' => 'datalist-strict', + 'item_type' => '', + 'list' => $listB->get('id'), + 'label' => 'Escalation Tier', + 'description' => 'Severity tier used to page the on-call rotation', + ]); + self::callMethod($updateForm, 'onSuccess', []); + + $relinkedListUuid = $dba->fetchOne( + $dba->select() + ->from('director_property_datalist', ['list_uuid']) + ->where('property_uuid = ?', DbUtil::quoteBinaryCompat($uuid->getBytes(), $dba)) + ); + $this->assertSame( + DbUtil::binaryResult($listB->get('uuid')), + DbUtil::binaryResult($relinkedListUuid), + 'property must be relinked to the newly selected datalist even though value_type did not change' + ); + } + + public function testUpdateDatalistPropertyChangesItemType(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $list = DirectorDatalist::create([ + 'list_name' => '___TEST___regions', + 'owner' => 'test', + ], $db); + $list->store(); + $this->createdDatalistNames[] = '___TEST___regions'; + + $createForm = new TestableCustomVariableForm($db); + $createForm->setTestValues([ + 'key_name' => '___TEST___allowed_regions', + 'value_type' => 'datalist-non-strict', + 'item_type' => 'string', + 'list' => $list->get('id'), + 'label' => 'Allowed Regions', + 'description' => 'Regions this host is permitted to be deployed in', + ]); + $this->createdKeyNames[] = '___TEST___allowed_regions'; + self::callMethod($createForm, 'onSuccess', []); + $uuid = $createForm->getUUid(); + + $updateForm = new TestableCustomVariableForm($db, $uuid); + $updateForm->setTestValues([ + 'key_name' => '___TEST___allowed_regions', + 'value_type' => 'datalist-non-strict', + 'item_type' => 'dynamic-array', + 'list' => $list->get('id'), + 'label' => 'Allowed Regions', + 'description' => 'Regions this host is permitted to be deployed in', + ]); + self::callMethod($updateForm, 'onSuccess', []); + + $childValueType = $dba->fetchOne( + $dba->select() + ->from('director_property', ['value_type']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($uuid->getBytes(), $dba)) + ->where('key_name = ?', '0') + ); + $this->assertSame( + 'dynamic-array', + $childValueType, + 'item type must be updated even though the datalist property type itself did not change' + ); + } + + public function testUpdateDynamicArrayPropertyChangesItemType(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $createForm = new TestableCustomVariableForm($db); + $createForm->setTestValues([ + 'key_name' => '___TEST___backup_ports', + 'value_type' => 'dynamic-array', + 'item_type' => 'string', + 'label' => 'Backup Ports', + 'description' => 'TCP ports used by the backup agent', + ]); + $this->createdKeyNames[] = '___TEST___backup_ports'; + self::callMethod($createForm, 'onSuccess', []); + $uuid = $createForm->getUUid(); + + $updateForm = new TestableCustomVariableForm($db, $uuid); + $updateForm->setTestValues([ + 'key_name' => '___TEST___backup_ports', + 'value_type' => 'dynamic-array', + 'item_type' => 'number', + 'label' => 'Backup Ports', + 'description' => 'TCP ports used by the backup agent', + ]); + self::callMethod($updateForm, 'onSuccess', []); + + $childValueType = $dba->fetchOne( + $dba->select() + ->from('director_property', ['value_type']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($uuid->getBytes(), $dba)) + ->where('key_name = ?', '0') + ); + $this->assertSame( + 'number', + $childValueType, + 'item type must be updated even though the dynamic-array property type itself did not change' + ); + } + + public function testRenamingUsedRootPropertyUpdatesHostVarnameEvenWithoutAPropertyUuidLink(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $host = IcingaHost::create([ + 'object_name' => '___TEST___switch03', + 'object_type' => 'object', + 'address' => '192.0.2.62', + ], $db); + $host->store(); + $this->createdHostNames[] = '___TEST___switch03'; + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___snmp_community', + 'value_type' => 'string', + 'label' => 'SNMP Community', + ], $db)->store(); + $this->createdKeyNames[] = '___TEST___snmp_community'; + $this->createdKeyNames[] = '___TEST___snmp_string'; + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___snmp_community', + 'varvalue' => json_encode('public'), + 'format' => 'json', + 'property_uuid' => null, + ]); + + $form = new TestableCustomVariableForm($db, $rootUuid); + self::callMethod($form, 'updateUsedCustomVarNames', ['___TEST___snmp_community', '___TEST___snmp_string']); + + $row = $dba->fetchRow( + $dba->select()->from('icinga_host_var', ['varname']) + ->where('host_id = ?', $host->get('id')) + ); + $this->assertNotFalse($row); + $this->assertSame( + '___TEST___snmp_string', + $row->varname, + 'the stored varname must follow the rename even though property_uuid was never linked' + ); + } + + public function testRenamingUsedFieldUpdatesHostVarValueEvenWithoutAPropertyUuidLink(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $host = IcingaHost::create([ + 'object_name' => '___TEST___switch04', + 'object_type' => 'object', + 'address' => '192.0.2.63', + ], $db); + $host->store(); + $this->createdHostNames[] = '___TEST___switch04'; + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___asset_info', + 'value_type' => 'fixed-dictionary', + 'label' => 'Asset Info', + ], $db)->store(); + $this->createdKeyNames[] = '___TEST___asset_info'; + + $fieldUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $fieldUuid->getBytes(), + 'key_name' => 'asset_type', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___asset_info', + 'varvalue' => json_encode(['asset_type' => 'switch', 'location' => 'rack-12']), + 'format' => 'json', + 'property_uuid' => null, + ]); + + $form = new TestableCustomVariableForm($db, $fieldUuid, true, $rootUuid); + self::callMethod($form, 'updateUsedCustomVarNames', ['asset_type', 'device_type']); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___asset_info') + ); + $this->assertEquals( + ['device_type' => 'switch', 'location' => 'rack-12'], + json_decode($updatedValue, true), + 'asset_type must be renamed to device_type in the stored value even though' + . ' property_uuid was never linked on that row' + ); + } + + public function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + foreach ($this->createdHostNames as $hostName) { + $dba->delete('icinga_host', ['object_name = ?' => $hostName]); + } + foreach ($this->createdKeyNames as $keyName) { + $rows = $dba->fetchAll( + $dba->select() + ->from('director_property', ['uuid']) + ->where('key_name = ?', $keyName) + ); + foreach ($rows as $row) { + $this->deletePropertyTree($dba, DbUtil::binaryResult($row->uuid)); + } + } + + foreach ($this->createdDatalistNames as $listName) { + if (DirectorDatalist::exists($listName, $db)) { + DirectorDatalist::load($listName, $db)->delete(); + } + } + } + + parent::tearDown(); + } + + /** + * Delete a director_property row along with all of its descendants, however deep the + * nesting goes. + * + * @return void + */ + private function deletePropertyTree($dba, string $uuid): void + { + $childUuids = array_map( + [DbUtil::class, 'binaryResult'], + $dba->fetchCol( + $dba->select()->from('director_property', ['uuid'])->where( + 'parent_uuid = ?', + DbUtil::quoteBinaryCompat($uuid, $dba) + ) + ) + ); + foreach ($childUuids as $childUuid) { + $this->deletePropertyTree($dba, $childUuid); + } + $dba->delete( + 'director_property_datalist', + $dba->quoteInto('property_uuid = ?', DbUtil::quoteBinaryCompat($uuid, $dba)) + ); + $dba->delete( + 'director_property', + $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($uuid, $dba)) + ); + } +} diff --git a/test/php/library/Director/Form/CustomVariablesFormTest.php b/test/php/library/Director/Form/CustomVariablesFormTest.php new file mode 100644 index 000000000..780313257 --- /dev/null +++ b/test/php/library/Director/Form/CustomVariablesFormTest.php @@ -0,0 +1,235 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form; + +use Icinga\Module\Director\Forms\CustomVariablesForm; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Test\BaseTestCase; +use LogicException; +use ReflectionMethod; + +class CustomVariablesFormTest extends BaseTestCase +{ + public function testAttachingNewPropertyToNonTemplateThrows(): void + { + $host = IcingaHost::create([ + 'object_name' => 'app-server-01', + 'object_type' => 'object', + ]); + $form = new CustomVariablesForm($host); + $method = new ReflectionMethod($form, 'assertCanAttachNewVariable'); + $method->setAccessible(true); + + $this->expectException(LogicException::class); + $method->invoke($form); + } + + public function testAttachingNewPropertyToTemplateIsAllowed(): void + { + $host = IcingaHost::create([ + 'object_name' => 'linux-server', + 'object_type' => 'template', + ]); + + $form = new CustomVariablesForm($host); + $method = new ReflectionMethod($form, 'assertCanAttachNewVariable'); + $method->setAccessible(true); + + $method->invoke($form); + $this->addToAssertionCount(1); + } + + public function testIsValueUnsetTreatsZeroStringAsSet(): void + { + $method = new ReflectionMethod(CustomVariablesForm::class, 'isValueUnset'); + $method->setAccessible(true); + + $this->assertFalse($method->invoke(null, '0')); + } + + public function testIsValueUnsetTreatsIntegerZeroAsSet(): void + { + $method = new ReflectionMethod(CustomVariablesForm::class, 'isValueUnset'); + $method->setAccessible(true); + + $this->assertFalse($method->invoke(null, 0)); + } + + public function testIsValueUnsetTreatsFloatZeroAsSet(): void + { + $method = new ReflectionMethod(CustomVariablesForm::class, 'isValueUnset'); + $method->setAccessible(true); + + $this->assertFalse($method->invoke(null, 0.0)); + } + + public function testIsValueUnsetTreatsFalseAsSet(): void + { + $method = new ReflectionMethod(CustomVariablesForm::class, 'isValueUnset'); + $method->setAccessible(true); + + $this->assertFalse($method->invoke(null, false)); + } + + public function testIsValueUnsetTreatsEmptyStringAsUnset(): void + { + $method = new ReflectionMethod(CustomVariablesForm::class, 'isValueUnset'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke(null, '')); + } + + public function testIsValueUnsetTreatsNullAsUnset(): void + { + $method = new ReflectionMethod(CustomVariablesForm::class, 'isValueUnset'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke(null, null)); + } + + public function testIsValueUnsetTreatsEmptyArrayAsUnset(): void + { + $method = new ReflectionMethod(CustomVariablesForm::class, 'isValueUnset'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke(null, [])); + } + + public function testOverrideWithoutHostThrows(): void + { + $service = IcingaService::create([ + 'object_name' => 'ping', + 'object_type' => 'template', + ]); + $form = new CustomVariablesForm($service); + $form->setApplyGenerated($service); + + $method = new ReflectionMethod($form, 'assertOverrideHostIsSet'); + $method->setAccessible(true); + + $this->expectException(LogicException::class); + $method->invoke($form); + } + + public function testOverrideWithHostIsAllowed(): void + { + $service = IcingaService::create([ + 'object_name' => 'ping', + 'object_type' => 'template', + ]); + $host = IcingaHost::create([ + 'object_name' => 'linux-server', + 'object_type' => 'template', + ]); + $form = new CustomVariablesForm($service); + $form->setApplyGenerated($service); + $form->setHostForService($host); + + $method = new ReflectionMethod($form, 'assertOverrideHostIsSet'); + $method->setAccessible(true); + + $method->invoke($form); + $this->addToAssertionCount(1); + } + + public function testNoOverrideRequestedDoesNotThrow(): void + { + $host = IcingaHost::create([ + 'object_name' => 'linux-server', + 'object_type' => 'template', + ]); + $form = new CustomVariablesForm($host); + + $method = new ReflectionMethod($form, 'assertOverrideHostIsSet'); + $method->setAccessible(true); + + $method->invoke($form); + $this->addToAssertionCount(1); + } + + public function testFiltersEmptyStrings(): void + { + $result = CustomVariablesForm::filterEmpty(['ssl_verify' => '', 'http_address' => 'monitor.example.com']); + $this->assertSame(['http_address' => 'monitor.example.com'], $result); + } + + public function testKeepsBooleans(): void + { + $result = CustomVariablesForm::filterEmpty(['ssl_verify' => false, 'check_freshness' => true]); + $this->assertSame(['ssl_verify' => false, 'check_freshness' => true], $result); + } + + public function testFiltersNullValues(): void + { + $result = CustomVariablesForm::filterEmpty(['display_name' => null, 'check_command' => 'ping']); + $this->assertSame(['check_command' => 'ping'], $result); + } + + public function testKeepsIntegerZero(): void + { + $result = CustomVariablesForm::filterEmpty(['retry_count' => 0, 'max_check_attempts' => 3]); + $this->assertSame(['retry_count' => 0, 'max_check_attempts' => 3], $result); + } + + public function testKeepsStringZero(): void + { + $result = CustomVariablesForm::filterEmpty(['exit_code' => '0', 'label' => 'ok']); + $this->assertSame(['exit_code' => '0', 'label' => 'ok'], $result); + } + + public function testKeepsFloatZero(): void + { + $result = CustomVariablesForm::filterEmpty(['load_offset' => 0.0, 'label' => 'ok']); + $this->assertSame(['load_offset' => 0.0, 'label' => 'ok'], $result); + } + + public function testFixedArrayWithLegitimateZeroIsNotDropped(): void + { + // The list branch must also treat a real 0 as "this list has content", not as empty. + $result = CustomVariablesForm::filterEmpty([0, '', '']); + $this->assertSame([0, '', ''], $result); + } + + public function testFiltersNestedEmptyArrays(): void + { + $result = CustomVariablesForm::filterEmpty(['disk_checks' => ['root' => ''], 'environment' => 'production']); + $this->assertSame(['environment' => 'production'], $result); + } + + public function testKeepsNestedArraysWithContent(): void + { + $input = ['disk_checks' => ['root' => '/'], 'environment' => 'production']; + $this->assertSame($input, CustomVariablesForm::filterEmpty($input)); + } + + public function testEmptyArrayReturnsEmpty(): void + { + $this->assertSame([], CustomVariablesForm::filterEmpty([])); + } + + public function testFixedArrayWithAllEmptySlotsIsDropped(): void + { + $result = CustomVariablesForm::filterEmpty(['', '', '']); + $this->assertSame([], $result); + } + + public function testFixedArrayNestedInsideDictionaryEntryKeepsItsSlots(): void + { + // e.g. a dynamic-dictionary entry ("dc1" => [...]) or a fixed-dictionary field that + // itself holds a fixed-array -- the nested fixed-array must not lose its positions. + $entry = ['label' => 'dc1', 'slots' => ['eth0', '', 'eth2']]; + $result = CustomVariablesForm::filterEmpty($entry); + $this->assertSame(['label' => 'dc1', 'slots' => ['eth0', '', 'eth2']], $result); + } + + public function testFixedArrayNestedInsideDictionaryEntryIsDroppedWhenFullyEmpty(): void + { + $entry = ['label' => 'dc1', 'slots' => ['', '', '']]; + $result = CustomVariablesForm::filterEmpty($entry); + $this->assertSame(['label' => 'dc1'], $result); + } +} diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php new file mode 100644 index 000000000..4684aed56 --- /dev/null +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -0,0 +1,1057 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form; + +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Forms\DeleteCustomVariableForm; +use Icinga\Module\Director\Objects\DirectorDatafieldCategory; +use Icinga\Module\Director\Objects\DirectorDatalist; +use Icinga\Module\Director\Objects\DirectorProperty; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Test\BaseTestCase; +use Ramsey\Uuid\Uuid; + +class DeleteCustomVariableFormTest extends BaseTestCase +{ + public function testDeleteStringPropertyRemovesRow(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $property = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '___TEST___environment', + 'value_type' => 'string', + 'label' => 'Environment Tag', + ], $db); + $property->store(); + + $form = new DeleteCustomVariableForm($db, [ + 'uuid' => $property->get('uuid'), + 'key_name' => '___TEST___environment', + 'value_type' => 'string', + 'label' => 'Environment Tag', + 'description' => null, + 'parent_uuid' => null, + ]); + + self::callMethod($form, 'onSuccess', []); + + $dba = $db->getDbAdapter(); + $row = $dba->fetchRow( + $dba->select() + ->from('director_property', ['uuid']) + ->where('key_name = ?', '___TEST___environment') + ); + $this->assertFalse($row, 'director_property row should be deleted'); + } + + public function testDeletePropertyWithChildrenRemovesBothRows(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $parentUuid = Uuid::uuid4(); + + $parent = DirectorProperty::create([ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___snmp_v3', + 'value_type' => 'fixed-dictionary', + 'label' => 'SNMPv3 Credentials', + ], $db); + $parent->store(); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'parent_uuid' => $parentUuid->getBytes(), + 'key_name' => 'auth_protocol', + 'value_type' => 'string', + 'label' => 'Auth Protocol', + ], $db); + $child->store(); + + $form = new DeleteCustomVariableForm($db, [ + 'uuid' => $parent->get('uuid'), + 'key_name' => '___TEST___snmp_v3', + 'value_type' => 'fixed-dictionary', + 'label' => 'SNMPv3 Credentials', + 'description' => null, + 'parent_uuid' => null, + ]); + + self::callMethod($form, 'onSuccess', []); + + $dba = $db->getDbAdapter(); + $parentRow = $dba->fetchRow( + $dba->select() + ->from('director_property', ['uuid']) + ->where('key_name = ?', '___TEST___snmp_v3') + ); + $this->assertFalse($parentRow, 'parent director_property row should be deleted'); + + $childRow = $dba->fetchRow( + $dba->select() + ->from('director_property', ['uuid']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $dba)) + ); + $this->assertFalse($childRow, 'child director_property row should be deleted'); + } + + public function testDeleteDatalistPropertyRemovesDatalistLink(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + $datalist = DirectorDatalist::create([ + 'list_name' => '___TEST___severity_levels', + 'owner' => 'test', + ], $db); + $datalist->store(); + + $property = DirectorProperty::import((object) [ + 'uuid' => Uuid::uuid4()->toString(), + 'key_name' => '___TEST___escalation_tier', + 'value_type' => 'datalist-strict', + 'label' => 'Escalation Tier', + 'description' => null, + 'parent_uuid' => null, + 'category' => null, + 'datalist' => '___TEST___severity_levels', + 'items' => [], + ], $db); + $property->store(); + + $form = new DeleteCustomVariableForm($db, [ + 'uuid' => $property->get('uuid'), + 'key_name' => '___TEST___escalation_tier', + 'value_type' => 'datalist-strict', + 'label' => 'Escalation Tier', + 'description' => null, + 'parent_uuid' => null, + ]); + + self::callMethod($form, 'onSuccess', []); + + $dba = $db->getDbAdapter(); + $linkRow = $dba->fetchRow( + $dba->select() + ->from('director_property_datalist', ['property_uuid']) + ->where('property_uuid = ?', DbUtil::quoteBinaryCompat($property->get('uuid'), $dba)) + ); + $this->assertFalse($linkRow, 'director_property_datalist link should be deleted'); + + $propRow = $dba->fetchRow( + $dba->select() + ->from('director_property', ['uuid']) + ->where('key_name = ?', '___TEST___escalation_tier') + ); + $this->assertFalse($propRow, 'director_property row should be deleted'); + } + + public function testUpdateFixedArrayItemsPreservesCategoryAndDatalistLink(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $datalist = DirectorDatalist::create([ + 'list_name' => '___TEST___fixed_array_datalist', + 'owner' => 'test', + ], $db); + $datalist->store(); + + $category = DirectorDatafieldCategory::create([ + 'category_name' => '___TEST___fixed_array_category', + ], $db); + $category->store(); + + $parentUuid = Uuid::uuid4(); + $parent = DirectorProperty::create([ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___fixed_array_parent', + 'value_type' => 'fixed-array', + 'label' => 'Fixed Array Parent', + ], $db); + $parent->store(); + + // Item 0 will be deleted. Item 1 is an innocent sibling that must survive the + // reindex triggered by that deletion with its category and datalist link intact. + $item0Uuid = Uuid::uuid4(); + $item0 = DirectorProperty::create([ + 'uuid' => $item0Uuid->getBytes(), + 'key_name' => '0', + 'parent_uuid' => $parentUuid->getBytes(), + 'value_type' => 'string', + ], $db); + $item0->store(); + + // Inserted directly (as the web forms do) rather than via DirectorProperty::store(), + // so this only exercises updateFixedArrayItems(), not property creation itself. + $item1Uuid = Uuid::uuid4(); + $dba->insert('director_property', [ + 'uuid' => DbUtil::quoteBinaryCompat($item1Uuid->getBytes(), $dba), + 'parent_uuid' => DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $dba), + 'key_name' => '1', + 'value_type' => 'datalist-strict', + 'category_id' => $category->get('id'), + 'label' => null, + 'description' => null, + ]); + $dba->insert('director_property_datalist', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($item1Uuid->getBytes(), $dba), + 'list_uuid' => DbUtil::quoteBinaryCompat($datalist->get('uuid'), $dba), + ]); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $item0Uuid->getBytes(), + 'key_name' => '0', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + 'parent_uuid' => $parentUuid->getBytes(), + ], + [ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___fixed_array_parent', + 'value_type' => 'fixed-array', + 'parent_uuid' => null, + ] + ); + + self::callMethod($form, 'onSuccess', []); + + $survivor = $dba->fetchRow( + $dba->select() + ->from('director_property', ['uuid', 'category_id']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $dba)) + ); + $this->assertNotFalse($survivor, 'surviving fixed-array item should still exist after the reindex'); + $this->assertEquals( + $category->get('id'), + (int) $survivor->category_id, + 'category_id must survive the fixed-array reindex' + ); + + $survivorUuid = DbUtil::binaryResult($survivor->uuid); + $linkRow = $dba->fetchRow( + $dba->select() + ->from('director_property_datalist', ['list_uuid']) + ->where('property_uuid = ?', DbUtil::quoteBinaryCompat($survivorUuid, $dba)) + ); + $this->assertNotFalse($linkRow, 'datalist link must survive the fixed-array reindex'); + } + + public function testDeletingRootFixedArrayItemUpdatesHostVarWithoutCrashing(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // A host backed up nightly, with a fixed-array custom variable listing the + // directories to include -- a realistic use of "fixed-array" in Icinga configs. + $host = IcingaHost::create([ + 'object_name' => '___TEST___backup01', + 'object_type' => 'object', + 'address' => '192.0.2.10', + ], $db); + $host->store(); + + $parentUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___backup_directories', + 'value_type' => 'fixed-array', + 'label' => 'Backup Directories', + ], $db)->store(); + + foreach (['0', '1', '2'] as $keyName) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $keyName, + 'parent_uuid' => $parentUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + } + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___backup_directories', + 'varvalue' => json_encode(['/etc', '/var/www', '/home']), + 'format' => 'json', + 'property_uuid' => DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $dba), + ]); + + // Delete the '/etc' entry (array index 0). + $item0Uuid = DbUtil::binaryResult($dba->fetchOne( + $dba->select()->from('director_property', ['uuid']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $dba)) + ->where('key_name = ?', '0') + )); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $item0Uuid, + 'key_name' => '0', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + 'parent_uuid' => $parentUuid->getBytes(), + ], + [ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___backup_directories', + 'value_type' => 'fixed-array', + 'parent_uuid' => null, + ] + ); + + self::callMethod($form, 'onSuccess', []); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___backup_directories') + ); + + $this->assertEquals( + ['/var/www', '/home'], + json_decode($updatedValue, true), + 'the remaining directories must be reindexed as a bare list, not wrapped under the array\'s own key name' + ); + } + + public function testUpdateFixedArrayItemsReindexesNumericallyNotLexicographically(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // A fixed array of 11 on-call escalation contacts (indices '0'..'10'): with more than + // 10 items, a lexicographic sort of key_name puts '10' right after '1', before '2'. + $parentUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___escalation_contacts', + 'value_type' => 'fixed-array', + 'label' => 'Escalation Contacts', + ], $db)->store(); + + for ($i = 0; $i <= 10; $i++) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => (string) $i, + 'parent_uuid' => $parentUuid->getBytes(), + 'value_type' => 'string', + 'label' => "Contact $i", + ], $db)->store(); + } + + // Delete contact '5'; the remaining 10 contacts must be reindexed to '0'..'9' in their + // original numeric order (Contact 0,1,2,3,4,6,7,8,9,10 -> key_name 0,1,2,3,4,5,6,7,8,9). + $deletedUuid = DbUtil::binaryResult($dba->fetchOne( + $dba->select()->from('director_property', ['uuid']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $dba)) + ->where('key_name = ?', '5') + )); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $deletedUuid, + 'key_name' => '5', + 'value_type' => 'string', + 'label' => 'Contact 5', + 'description' => null, + 'parent_uuid' => $parentUuid->getBytes(), + ], + [ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___escalation_contacts', + 'value_type' => 'fixed-array', + 'parent_uuid' => null, + ] + ); + + self::callMethod($form, 'onSuccess', []); + + $rows = $dba->fetchAll( + $dba->select()->from('director_property', ['key_name', 'label']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $dba)) + ); + $labelsByKeyName = []; + foreach ($rows as $row) { + $labelsByKeyName[$row->key_name] = $row->label; + } + + // Numeric order (0,1,2,3,4,6,7,8,9,10 -> new keys 0..9) puts "Contact 2" at key_name '2' + // and "Contact 10" at key_name '9'. A lexicographic sort ('0','1','10','2',...) would + // instead put "Contact 10" at key_name '2' and "Contact 2" at key_name '3'. + $this->assertEquals( + 'Contact 2', + $labelsByKeyName['2'] ?? null, + 'key_name 2 must still refer to the item originally labeled "Contact 2"' + ); + $this->assertEquals( + 'Contact 10', + $labelsByKeyName['9'] ?? null, + 'the item originally labeled "Contact 10" must end up last (key_name 9), not' + . ' misplaced by a lexicographic sort' + ); + } + + public function testOnSuccessRollsBackTransactionWhenAnExceptionIsThrown(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // A host maintenance window dictionary with one field ("reason"); an unrelated failure + // partway through processing must not leave the transaction dangling. + $parentUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___maintenance_window', + 'value_type' => 'fixed-dictionary', + 'label' => 'Maintenance Window', + ], $db)->store(); + + $childUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $childUuid->getBytes(), + 'key_name' => 'reason', + 'parent_uuid' => $parentUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $host = IcingaHost::create([ + 'object_name' => '___TEST___maintenance01', + 'object_type' => 'object', + 'address' => '192.0.2.12', + ], $db); + $host->store(); + + // A malformed stored value (JSON literal null, decoding to PHP null) makes + // removeDictionaryItem() receive null where an array is required, throwing a + // TypeError partway through onSuccess() -- simulating any unexpected mid-transaction + // failure. + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___maintenance_window', + 'varvalue' => 'null', + 'format' => 'json', + 'property_uuid' => DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $dba), + ]); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $childUuid->getBytes(), + 'key_name' => 'reason', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + 'parent_uuid' => $parentUuid->getBytes(), + ], + [ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___maintenance_window', + 'value_type' => 'fixed-dictionary', + 'parent_uuid' => null, + ] + ); + + try { + self::callMethod($form, 'onSuccess', []); + $this->fail('Expected an exception while processing the malformed stored value'); + } catch (\Throwable $e) { + // expected + } + + $this->assertFalse( + $dba->getConnection()->inTransaction(), + 'onSuccess() must roll back its transaction when interrupted by an exception' + ); + } + + public function testDeletingRootPropertyRemovesHostVarEvenWithoutAPropertyUuidLink(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $host = IcingaHost::create([ + 'object_name' => '___TEST___switch02', + 'object_type' => 'object', + 'address' => '192.0.2.61', + ], $db); + $host->store(); + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___snmp_community', + 'value_type' => 'string', + 'label' => 'SNMP Community', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___snmp_community', + 'varvalue' => json_encode('public'), + 'format' => 'json', + 'property_uuid' => null, + ]); + + $form = new DeleteCustomVariableForm($db, [ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___snmp_community', + 'value_type' => 'string', + 'label' => 'SNMP Community', + 'description' => null, + 'parent_uuid' => null, + ]); + + self::callMethod($form, 'onSuccess', []); + + $row = $dba->fetchRow( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___snmp_community') + ); + $this->assertFalse($row, 'the host_var row must be dropped by varname even without a property_uuid link'); + } + + public function testDeletingDynamicDictionaryFieldUpdatesEveryEntryInHostVar(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // A host's per-datacenter settings: a dynamic dictionary keyed by datacenter name, + // each entry holding a contact_email and timezone. Deleting contact_email must + // strip it from every datacenter's entry, even when the entry doesn't become empty. + $host = IcingaHost::create([ + 'object_name' => '___TEST___multidc01', + 'object_type' => 'object', + 'address' => '192.0.2.40', + ], $db); + $host->store(); + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___datacenter_settings', + 'value_type' => 'dynamic-dictionary', + 'label' => 'Datacenter Settings', + ], $db)->store(); + + $contactEmailUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $contactEmailUuid->getBytes(), + 'key_name' => 'contact_email', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___datacenter_settings', + 'varvalue' => json_encode([ + 'dc1' => ['contact_email' => 'dc1@example.com', 'timezone' => 'UTC'], + 'dc2' => ['contact_email' => 'dc2@example.com', 'timezone' => 'PST'], + ]), + 'format' => 'json', + 'property_uuid' => DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba), + ]); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $contactEmailUuid->getBytes(), + 'key_name' => 'contact_email', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + 'parent_uuid' => $rootUuid->getBytes(), + ], + [ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___datacenter_settings', + 'value_type' => 'dynamic-dictionary', + 'parent_uuid' => null, + ] + ); + + self::callMethod($form, 'onSuccess', []); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___datacenter_settings') + ); + + $this->assertEquals( + [ + 'dc1' => ['timezone' => 'UTC'], + 'dc2' => ['timezone' => 'PST'], + ], + json_decode($updatedValue, true), + 'contact_email must be removed from every datacenter entry, not just entries' + . ' that become fully empty as a result' + ); + } + + public function testDeletingDynamicDictionaryFieldConvertsEmptiedEntryToEmptyObjectInHostVar(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // Same datacenter-settings dictionary, but dc2 has only the field being deleted -- + // its entry must become an empty object (still a dictionary), not be dropped or + // turned into an empty list. + $host = IcingaHost::create([ + 'object_name' => '___TEST___multidc02', + 'object_type' => 'object', + 'address' => '192.0.2.41', + ], $db); + $host->store(); + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___datacenter_settings_2', + 'value_type' => 'dynamic-dictionary', + 'label' => 'Datacenter Settings 2', + ], $db)->store(); + + $contactEmailUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $contactEmailUuid->getBytes(), + 'key_name' => 'contact_email', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___datacenter_settings_2', + 'varvalue' => json_encode([ + 'dc1' => ['contact_email' => 'dc1@example.com', 'timezone' => 'UTC'], + 'dc2' => ['contact_email' => 'dc2@example.com'], + ]), + 'format' => 'json', + 'property_uuid' => DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba), + ]); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $contactEmailUuid->getBytes(), + 'key_name' => 'contact_email', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + 'parent_uuid' => $rootUuid->getBytes(), + ], + [ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___datacenter_settings_2', + 'value_type' => 'dynamic-dictionary', + 'parent_uuid' => null, + ] + ); + + self::callMethod($form, 'onSuccess', []); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___datacenter_settings_2') + ); + + $this->assertEquals( + '{"dc1":{"timezone":"UTC"},"dc2":{}}', + $updatedValue, + 'an entry emptied by the deletion must be encoded as an empty object, not dropped' + . ' or turned into an empty list' + ); + } + + public function testDeletingDynamicDictionaryFieldUpdatesEveryEntryInOverrideServiceVars(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // The same per-datacenter settings dictionary, but overridden for a specific service + // via _override_servicevars, with one datacenter entry fully emptied by the deletion. + $host = IcingaHost::create([ + 'object_name' => '___TEST___multidc03', + 'object_type' => 'object', + 'address' => '192.0.2.42', + ], $db); + $host->store(); + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___datacenter_settings_3', + 'value_type' => 'dynamic-dictionary', + 'label' => 'Datacenter Settings 3', + ], $db)->store(); + + $contactEmailUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $contactEmailUuid->getBytes(), + 'key_name' => 'contact_email', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '_override_servicevars', + 'varvalue' => json_encode([ + 'web_check' => [ + '___TEST___datacenter_settings_3' => [ + 'dc1' => ['contact_email' => 'dc1@example.com', 'timezone' => 'UTC'], + 'dc2' => ['contact_email' => 'dc2@example.com'], + ], + ], + ]), + 'format' => 'json', + ]); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $contactEmailUuid->getBytes(), + 'key_name' => 'contact_email', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + 'parent_uuid' => $rootUuid->getBytes(), + ], + [ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___datacenter_settings_3', + 'value_type' => 'dynamic-dictionary', + 'parent_uuid' => null, + ] + ); + + self::callMethod($form, 'onSuccess', []); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '_override_servicevars') + ); + + $this->assertEquals( + [ + 'web_check' => [ + '___TEST___datacenter_settings_3' => [ + 'dc1' => ['timezone' => 'UTC'], + ], + ], + ], + json_decode($updatedValue, true), + 'contact_email must be stripped from every datacenter entry, and an entry fully' + . ' emptied by the deletion must be dropped from the override' + ); + } + + public function testDeletingFieldUpdatesServiceVarWithoutCrashing(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // Existing coverage for removeObjectCustomVars() only ever touches + // icinga_host_var. The cleaner walks host, service, notification, command + // and user tables the same way, so a service-attached field needs cleanup too. + $service = IcingaService::create([ + 'object_name' => '___TEST___load-check', + 'object_type' => 'template', + 'display_name' => 'Load Check', + ], $db); + $service->store(); + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___load_thresholds', + 'value_type' => 'fixed-dictionary', + 'label' => 'Load Thresholds', + ], $db)->store(); + + $warnUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $warnUuid->getBytes(), + 'key_name' => 'warn', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_service_var', [ + 'service_id' => $service->get('id'), + 'varname' => '___TEST___load_thresholds', + 'varvalue' => json_encode(['warn' => '5', 'crit' => '10']), + 'format' => 'json', + 'property_uuid' => DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba), + ]); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $warnUuid->getBytes(), + 'key_name' => 'warn', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + 'parent_uuid' => $rootUuid->getBytes(), + ], + [ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___load_thresholds', + 'value_type' => 'fixed-dictionary', + 'parent_uuid' => null, + ] + ); + + self::callMethod($form, 'onSuccess', []); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_service_var', ['varvalue']) + ->where('service_id = ?', $service->get('id')) + ->where('varname = ?', '___TEST___load_thresholds') + ); + + $this->assertEquals( + ['crit' => '10'], + json_decode($updatedValue, true), + 'warn must be removed from the service-attached fixed-dictionary value' + ); + + $service->delete(); + $dba->delete('director_property', ['key_name = ?' => '___TEST___load_thresholds']); + } + + public function testFixedArrayReindexPreservesRequiredFlagOnSurvivingItem(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // A fixed array of on-call contact numbers, defined on a host template. The second + // contact has been marked "required" directly via icinga_host_property. Deleting the + // first contact forces this survivor to be renumbered from key_name '1' to '0' -- the + // renumbering must not lose the "required" binding along the way. + $host = IcingaHost::create([ + 'object_name' => '___TEST___oncall_template', + 'object_type' => 'template', + ], $db); + $host->store(); + + $parentUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___contact_numbers', + 'value_type' => 'fixed-array', + 'label' => 'Contact Numbers', + ], $db)->store(); + + $item0Uuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $item0Uuid->getBytes(), + 'key_name' => '0', + 'parent_uuid' => $parentUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $item1Uuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $item1Uuid->getBytes(), + 'key_name' => '1', + 'parent_uuid' => $parentUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_property', [ + 'host_uuid' => DbUtil::quoteBinaryCompat($host->get('uuid'), $dba), + 'property_uuid' => DbUtil::quoteBinaryCompat($item1Uuid->getBytes(), $dba), + 'required' => 'y', + ]); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $item0Uuid->getBytes(), + 'key_name' => '0', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + 'parent_uuid' => $parentUuid->getBytes(), + ], + [ + 'uuid' => $parentUuid->getBytes(), + 'key_name' => '___TEST___contact_numbers', + 'value_type' => 'fixed-array', + 'parent_uuid' => null, + ] + ); + + self::callMethod($form, 'onSuccess', []); + + $survivor = $dba->fetchRow( + $dba->select()->from('director_property', ['uuid', 'key_name']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $dba)) + ); + $this->assertNotFalse($survivor, 'the surviving item must still exist after the reindex'); + $this->assertEquals('0', $survivor->key_name, 'the survivor must be renumbered to key_name 0'); + + $survivorUuid = DbUtil::binaryResult($survivor->uuid); + $requiredRow = $dba->fetchRow( + $dba->select()->from('icinga_host_property', ['required']) + ->where('property_uuid = ?', DbUtil::quoteBinaryCompat($survivorUuid, $dba)) + ); + + $this->assertNotFalse( + $requiredRow, + 'the required binding on the surviving item must not be lost by the reindex' + ); + } + + public function tearDown(): void + { + if ($this->hasDb()) { + $dba = $this->getDb()->getDbAdapter(); + // A test exercising the no-rollback bug can leave a transaction open if the fix + // under test regresses; clear it first so cleanup below (and later tests) aren't + // run inside a stale, never-committed transaction. + if ($dba->getConnection()->inTransaction()) { + $dba->getConnection()->rollBack(); + } + // Delete hosts (cascades to icinga_host_var) before director_property rows below — + // icinga_host_var.property_uuid has no ON DELETE CASCADE to director_property. + $hostNames = [ + '___TEST___backup01', + '___TEST___maintenance01', + '___TEST___multidc01', + '___TEST___multidc02', + '___TEST___multidc03', + '___TEST___oncall_template', + '___TEST___switch02', + ]; + foreach ($hostNames as $hostName) { + $dba->delete('icinga_host', ['object_name = ?' => $hostName]); + } + $keyNames = [ + '___TEST___environment', + '___TEST___snmp_v3', + '___TEST___escalation_tier', + '___TEST___fixed_array_parent', + '___TEST___backup_directories', + '___TEST___escalation_contacts', + '___TEST___maintenance_window', + '___TEST___datacenter_settings', + '___TEST___datacenter_settings_2', + '___TEST___datacenter_settings_3', + '___TEST___contact_numbers', + '___TEST___snmp_community', + ]; + foreach ($keyNames as $keyName) { + $rows = $dba->fetchAll( + $dba->select() + ->from('director_property', ['uuid']) + ->where('key_name = ?', $keyName) + ); + foreach ($rows as $row) { + // Read the (possibly stream-backed, on PostgreSQL) uuid column exactly once — + // a PHP stream resource can only be consumed a single time. + $this->deletePropertyTree($dba, DbUtil::binaryResult($row->uuid)); + } + } + + $dba->delete('director_datalist', ['list_name = ?' => '___TEST___severity_levels']); + $dba->delete('director_datalist', ['list_name = ?' => '___TEST___fixed_array_datalist']); + $dba->delete('director_datafield_category', ['category_name = ?' => '___TEST___fixed_array_category']); + } + + parent::tearDown(); + } + + /** + * Delete a director_property row along with all of its descendants, however deep the + * nesting goes, cleaning up their director_property_datalist links as we go. + */ + private function deletePropertyTree($dba, string $uuid): void + { + $childUuids = array_map( + [DbUtil::class, 'binaryResult'], + $dba->fetchCol( + $dba->select()->from('director_property', ['uuid'])->where( + 'parent_uuid = ?', + DbUtil::quoteBinaryCompat($uuid, $dba) + ) + ) + ); + foreach ($childUuids as $childUuid) { + $this->deletePropertyTree($dba, $childUuid); + } + $dba->delete( + 'director_property_datalist', + $dba->quoteInto('property_uuid = ?', DbUtil::quoteBinaryCompat($uuid, $dba)) + ); + $dba->delete( + 'director_property', + $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($uuid, $dba)) + ); + } +} diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php new file mode 100644 index 000000000..3749e5878 --- /dev/null +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -0,0 +1,1018 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form\DictionaryElements; + +use Icinga\Application\Config; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Forms\DictionaryElements\Dictionary; +use Icinga\Module\Director\Forms\DictionaryElements\DictionaryItem; +use Icinga\Module\Director\Objects\DirectorProperty; +use Icinga\Module\Director\Test\BaseTestCase; +use Icinga\Module\Director\Web\Form\Element\SensitiveElement; +use Ramsey\Uuid\Uuid; +use Tests\Icinga\Module\Director\Form\Lib\TestableDictionaryItem; + +/** + * Currently only sensitive types are tested, the tests need to be extended + * to cover all types. + */ +class DictionaryItemTest extends BaseTestCase +{ + private const PREFIX = '___TEST___'; + + /** @var string[] key_names of root properties created in tests (for tearDown) */ + private array $createdKeyNames = []; + + public function setUp(): void + { + parent::setUp(); + + // DictionaryItem resolves its own db connection via + // Config::module('director')->get('db', 'resource'), independently of + // BaseTestCase's own db handling. Point it at the very same resources.ini + // entry BaseTestCase uses, so both sides talk to the same test database. + if ($this->hasDb()) { + Config::module('director')->setSection('db', ['resource' => static::getDbResourceName()]); + } + } + + public function testPrepareScrubsInheritedSecretButKeepsItsPresenceForSensitiveType(): void + { + $property = [ + 'uuid' => '', + 'key_name' => 'api_token', + 'label' => 'API Token', + 'value_type' => 'sensitive', + 'value' => 's3cr3t-current-value', + 'inherited' => 's3cr3t-inherited-value', + 'inherited_from' => 'webserver-template', + ]; + + $result = DictionaryItem::prepare($property); + + $this->assertStringNotContainsString('s3cr3t-inherited-value', $result['inherited']); + $this->assertNotEmpty($result['inherited'], 'presence of an inherited value must still be signaled'); + } + + public function testPrepareLeavesInheritedEmptyWhenNothingIsInheritedForSensitiveType(): void + { + $property = [ + 'uuid' => '', + 'key_name' => 'api_token', + 'label' => 'API Token', + 'value_type' => 'sensitive', + 'value' => 's3cr3t-current-value', + ]; + + $result = DictionaryItem::prepare($property); + + $this->assertSame('', $result['inherited']); + } + + public function testPrepareMasksAStoredSensitiveValueRatherThanExposingItInTheForm(): void + { + // The field can't tell a stored secret apart from a value the user just typed. + // So prepare() must never send the real secret at all, or it would end up in + // the page source on the very first load. + $property = [ + 'uuid' => '', + 'key_name' => 'api_token', + 'label' => 'API Token', + 'value_type' => 'sensitive', + 'value' => 's3cr3t-current-value', + ]; + + $result = DictionaryItem::prepare($property); + + $this->assertSame(SensitiveElement::DUMMYPASSWORD, $result['var']); + } + + public function testPrepareLeavesVarEmptyWhenThereIsNoStoredSensitiveValue(): void + { + $property = [ + 'uuid' => '', + 'key_name' => 'api_token', + 'label' => 'API Token', + 'value_type' => 'sensitive', + ]; + + $result = DictionaryItem::prepare($property); + + $this->assertSame('', $result['var']); + } + + public function testGetItemDefaultsSensitiveValueToEmptyStringInFixedArray(): void + { + $item = new TestableDictionaryItem('0', []); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-array', + ]); + $item->ensureAssembled(); + + $this->assertSame('', $item->getItem()['value']); + } + + public function testGetItemPreservesEnteredSensitiveValue(): void + { + $item = new TestableDictionaryItem('api_token', []); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-dictionary', + 'var' => 's3cr3t-value', + ]); + $item->ensureAssembled(); + + $this->assertSame('s3cr3t-value', $item->getItem()['value']); + } + + public function testGetItemDefaultsSensitiveValueToEmptyStringWhenInheritedAndLeftBlank(): void + { + // The parent template already has a value here (e.g. an SNMP community string + // like "public"), so 'inherited' is set. The user leaves the field blank, so we + // just keep the inherited value. This slot must store '' here, not null. + $item = new TestableDictionaryItem('3', []); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-array', + 'inherited' => '1', + ]); + $item->ensureAssembled(); + + $this->assertSame('', $item->getItem()['value']); + } + + public function testGetItemUsesNumberDefaultForFixedArrayItemWithInheritedValueLeftBlank(): void + { + // Fixed arrays are all-or-nothing: touching one item un-inherits the whole + // array, so an untouched sibling must fall back to its type's own default + // (0 for 'number'), not stay null or show the stale inherited value. + $item = new TestableDictionaryItem('1', []); + $item->setTestConfig([ + 'type' => 'number', + 'parent_type' => 'fixed-array', + 'inherited' => '1', + ]); + $item->ensureAssembled(); + + $this->assertSame(0, $item->getItem()['value']); + } + + public function testGetItemSuppressesNumberDefaultWhenDefaultsAreDisabled(): void + { + // Storage wants the type default here, a required check doesn't, or the + // defaulted 0 would read as a real value. + $item = new TestableDictionaryItem('1', []); + $item->setTestConfig([ + 'type' => 'number', + 'parent_type' => 'fixed-array', + ]); + $item->ensureAssembled(); + + $this->assertNull($item->getItem(false)['value']); + } + + public function testGetItemPreservesExistingSensitiveValueWhenLeftUnchanged(): void + { + // Left untouched means the browser sends back the DUMMYPASSWORD placeholder, + // not an empty string. Only that should keep the old secret, otherwise there + // would be no way to ever clear it. + $item = new TestableDictionaryItem('api_token', ['value' => 's3cr3t-existing-value']); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-dictionary', + 'var' => SensitiveElement::DUMMYPASSWORD, + ]); + $item->ensureAssembled(); + + $this->assertSame('s3cr3t-existing-value', $item->getItem()['value']); + } + + public function testGetItemClearsExistingSensitiveValueWhenExplicitlyEmptied(): void + { + // An empty submission means the user cleared the field, so it must clear the + // stored secret. + $item = new TestableDictionaryItem('api_token', ['value' => 's3cr3t-existing-value']); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-dictionary', + 'var' => '', + ]); + $item->ensureAssembled(); + + $this->assertSame('', $item->getItem()['value']); + } + + public function testGetItemPreservesExistingSensitiveValueWhenInheritedAndLeftUnchanged(): void + { + // 'inherited' is set whenever any parent also defines this property, even if + // this object has its own value too. So getItem()'s "inherited" branch needs + // the same unchanged-vs-cleared check as above. + $item = new TestableDictionaryItem('api_token', ['value' => 's3cr3t-existing-value']); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-dictionary', + 'inherited' => '1', + 'var' => SensitiveElement::DUMMYPASSWORD, + ]); + $item->ensureAssembled(); + + $this->assertSame('s3cr3t-existing-value', $item->getItem()['value']); + } + + public function testGetItemClearsExistingSensitiveValueWhenInheritedAndExplicitlyEmptied(): void + { + $item = new TestableDictionaryItem('api_token', ['value' => 's3cr3t-existing-value']); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-dictionary', + 'inherited' => '1', + 'var' => '', + ]); + $item->ensureAssembled(); + + $this->assertSame('', $item->getItem()['value']); + } + + public function testGetItemPreservesExistingSensitiveValueOfZeroWhenLeftUnchanged(): void + { + // PHP's empty() treats the string "0" as empty, but a sensitive value of "0" + // (e.g. a numeric PIN or token) is still a real, previously-set secret that an + // unchanged submission must not silently erase. + $item = new TestableDictionaryItem('api_token', ['value' => '0']); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-dictionary', + 'var' => SensitiveElement::DUMMYPASSWORD, + ]); + $item->ensureAssembled(); + + $this->assertSame('0', $item->getItem()['value']); + } + + public function testFullyInheritedFixedArrayStaysInheritedWhenNothingIsTouched(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // Inherited from a base template and never touched, must not turn into a + // locally defaulted copy on save. + $dictionaryItem = $this->buildDiskMirrorsDictionaryItem(); + + $result = $dictionaryItem->getItem(); + + $this->assertArrayNotHasKey('value', $result); + } + + public function testFixedArrayMaterializesAsLocalValueWhenOneChildIsChanged(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildDiskMirrorsDictionaryItem(); + $this->findNestedItem($dictionaryItem, '1')->getElement('var')->setValue('mirror2-standby.example.com'); + + $result = $dictionaryItem->getItem(); + + $this->assertSame(['', 'mirror2-standby.example.com', ''], $result['value']); + } + + public function testFixedArrayWithExistingLocalValueStaysUntouchedWhenNothingIsEdited(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // Already has its own local override, nothing here got edited either. + $dictionaryItem = $this->buildDiskMirrorsDictionaryItem([ + 'mirror1.example.com', + 'mirror2.example.com', + 'mirror3.example.com', + ]); + + $result = $dictionaryItem->getItem(); + + $this->assertArrayNotHasKey('value', $result); + } + + public function testFixedArrayWithExistingLocalValueMaterializesWhenOneChildIsChanged(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildDiskMirrorsDictionaryItem([ + 'mirror1.example.com', + 'mirror2.example.com', + 'mirror3.example.com', + ]); + $this->findNestedItem($dictionaryItem, '1')->getElement('var')->setValue('mirror2-standby.example.com'); + + $result = $dictionaryItem->getItem(); + + $this->assertSame( + ['mirror1.example.com', 'mirror2-standby.example.com', 'mirror3.example.com'], + $result['value'] + ); + } + + public function testFixedArrayWithOneStoredSlotStaysUntouchedWhenNothingIsEdited(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // Only one slot was ever filled in, the rest must stay empty rather than + // getting a type default just because a sibling holds a real value. + $dictionaryItem = $this->buildDiskMirrorsDictionaryItem([1 => 'mirror2.example.com']); + + $result = $dictionaryItem->getItem(); + + $this->assertArrayNotHasKey('value', $result); + } + + public function testClearingAPreviouslyStoredNumberDoesNotBounceBackToItsTypeDefault(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // Clearing the primary host and the retry count must give back null for the + // count, not bounce back to 0 just because it is blank now too. + $db = $this->getDb(); + $parentUuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'backup_targets'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-array', + 'label' => 'Backup Targets', + ], $db)->store(); + + foreach (['0' => 'string', '1' => 'string', '2' => 'number', '3' => 'string'] as $position => $valueType) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $position, + 'parent_uuid' => $parentUuidBytes, + 'value_type' => $valueType, + ], $db)->store(); + } + + $property = [ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-array', + 'label' => 'Backup Targets', + 'value' => ['backup1.example.com', '', 3, ''], + ]; + + $dictionaryItem = new DictionaryItem('0', $property); + $dictionaryItem->populate(DictionaryItem::prepare($property)); + $dictionaryItem->ensureAssembled(); + + $this->findNestedItem($dictionaryItem, '0')->getElement('var')->setValue(''); + $this->findNestedItem($dictionaryItem, '2')->getElement('var')->setValue(''); + + $result = $dictionaryItem->getItem(); + + $this->assertSame([null, '', null, ''], $result['value']); + } + + public function testRequiredScalarFieldIsMarkedRequiredOnTheElement(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItem(required: true); + + $this->assertTrue($item->getElement('var')->isRequired()); + } + + public function testNonRequiredScalarFieldIsNotMarkedRequired(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItem(required: false); + + $this->assertFalse($item->getElement('var')->isRequired()); + } + + public function testRequiredScalarFieldFailsValidationWhenLeftBlank(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItem(required: true); + $item->getElement('var')->setValue(''); + + $this->assertFalse($item->getElement('var')->validate()->isValid()); + } + + public function testRequiredScalarFieldPassesValidationWhenFilled(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItem(required: true); + $item->getElement('var')->setValue('production'); + + $this->assertTrue($item->getElement('var')->validate()->isValid()); + } + + public function testRequiredFixedDictionaryFailsValidationWhenEveryChildIsBlank(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // The nested Dictionary always carries a value thanks to its own hidden + // bookkeeping fields (item-count, items_removed...), so this only proves + // anything if the required check looks past those and into the real children. + $item = $this->buildRequiredRegionSettingsDictionaryItem(); + $item->ensureAssembled(); + + $this->assertFalse($item->getElement('var')->validate()->isValid()); + } + + public function testRequiredFixedDictionaryPassesValidationWhenOneChildIsFilled(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildRequiredRegionSettingsDictionaryItem(); + $item->ensureAssembled(); + $this->findNestedItem($item, 'region')->getElement('var')->setValue('us-east'); + + $this->assertTrue($item->getElement('var')->validate()->isValid()); + } + + public function testRequiredScalarIsNotMarkedRequiredWhenInherited(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildRequiredScalarDictionaryItemWithInheritedValue(); + + $this->assertFalse($item->getElement('var')->isRequired()); + } + + public function testRequiredScalarPassesValidationWhenLeftBlankButInherited(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // A parent template already provides this value, so leaving it blank must + // not fail required, it still resolves to something real. + $item = $this->buildRequiredScalarDictionaryItemWithInheritedValue(); + $item->getElement('var')->setValue(''); + + $this->assertTrue($item->getElement('var')->validate()->isValid()); + } + + public function testRequiredFixedArrayPassesValidationWhenFullyInherited(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // Nothing local was ever set, it's all inherited from a parent template. + // That value only ever lands on the children, never the property itself. + $dictionaryItem = $this->buildDiskMirrorsDictionaryItem(required: true); + + $this->assertTrue($dictionaryItem->getElement('var')->validate()->isValid()); + } + + public function testRequiredFixedArrayFailsValidationWhenEveryChildIsBlank(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // Nothing inherited, nothing filled in. A blank number/bool child falls back + // to a storage default (0, 'n') that must not read as a real value here. + $item = $this->buildRequiredServerSettingsDictionaryItem(); + $item->ensureAssembled(); + + $this->assertFalse($item->getElement('var')->validate()->isValid()); + } + + public function testRequiredFixedArrayPassesValidationWhenTheNumberChildIsFilled(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildRequiredServerSettingsDictionaryItem(); + $item->ensureAssembled(); + $this->findNestedItem($item, '1')->getElement('var')->setValue('8080'); + + $this->assertTrue($item->getElement('var')->validate()->isValid()); + } + + public function testRequiredToggleIsRenderedNextToTheRemoveButton(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItemWithRemoveButton(requiredCurrent: true); + + $this->assertTrue($item->hasElement('item_required_' . $item->getName())); + } + + public function testRequiredToggleIsSeededFromTheStoredRequiredFlag(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItemWithRemoveButton(requiredCurrent: true); + + $this->assertSame('y', $item->getElement('item_required_' . $item->getName())->getValue()); + } + + public function testRequiredToggleIsNotRenderedWithoutARemoveButton(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // No remove button, e.g. rendering for a non-template object or an inherited row. + $item = $this->buildScalarDictionaryItem(required: false); + + $this->assertFalse($item->hasElement('item_required_' . $item->getName())); + } + + public function testGetItemReportsTheToggledRequiredFlag(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItemWithRemoveButton(requiredCurrent: true); + $item->getElement('item_required_' . $item->getName())->setValue('n'); + + $this->assertFalse($item->getItem()['required']); + } + + public function testGetItemOmitsRequiredFlagWhenThereIsNoToggle(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItem(required: false); + + $this->assertArrayNotHasKey('required', $item->getItem()); + } + + /** + * Build a required or optional 'environment' string DictionaryItem, backed by a + * real director_property row with no children, so assemble()'s unconditional + * fetchChildrenItems() call has something to query against. + */ + private function buildScalarDictionaryItem(bool $required): DictionaryItem + { + $db = $this->getDb(); + $uuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'environment'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $uuidBytes, + 'key_name' => $keyName, + 'value_type' => 'string', + 'label' => 'Environment', + ], $db)->store(); + + $item = new DictionaryItem('0', [ + 'uuid' => $uuidBytes, + 'key_name' => $keyName, + 'value_type' => 'string', + 'label' => 'Environment', + 'required' => $required, + ]); + $item->ensureAssembled(); + + return $item; + } + + /** + * Same as buildScalarDictionaryItem(), but with a remove button set beforehand, the + * same way Dictionary::assemble() wires one up for a directly-attached property on + * a template's own tab. That's what makes the required toggle appear. + */ + private function buildScalarDictionaryItemWithRemoveButton(bool $requiredCurrent): DictionaryItem + { + $db = $this->getDb(); + $uuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'environment'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $uuidBytes, + 'key_name' => $keyName, + 'value_type' => 'string', + 'label' => 'Environment', + ], $db)->store(); + + $item = new DictionaryItem('0', [ + 'uuid' => $uuidBytes, + 'key_name' => $keyName, + 'value_type' => 'string', + 'label' => 'Environment', + 'required_current' => $requiredCurrent, + ]); + + $removeButton = $item->createElement('submitButton', 'remove_0', ['label' => 'Remove Item']); + $item->setRemoveButton($removeButton); + $item->ensureAssembled(); + + return $item; + } + + /** + * Build a required fixed-dictionary DictionaryItem ("region_settings") with two + * string children ("region", "zone"), neither of them populated. + */ + private function buildRequiredRegionSettingsDictionaryItem(): DictionaryItem + { + $db = $this->getDb(); + $parentUuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'region_settings'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-dictionary', + 'label' => 'Region Settings', + ], $db)->store(); + + foreach (['region', 'zone'] as $childKeyName) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $childKeyName, + 'parent_uuid' => $parentUuidBytes, + 'value_type' => 'string', + ], $db)->store(); + } + + return new DictionaryItem('0', [ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-dictionary', + 'label' => 'Region Settings', + 'required' => true, + ]); + } + + public function testMergeChildValuesAttachesMatchingValueByKeyName(): void + { + $propertyItems = [ + ['key_name' => 'threshold', 'value_type' => 'number'], + ]; + $values = ['value' => ['threshold' => 5]]; + + $result = DictionaryItem::mergeChildValues($propertyItems, 'fixed-dictionary', $values); + + $this->assertSame(5, $result['threshold']['value']); + } + + public function testMergeChildValuesAttachesMatchingInheritedValueAndItsSource(): void + { + $propertyItems = [ + ['key_name' => 'community', 'value_type' => 'sensitive'], + ]; + $values = [ + 'inherited' => ['community' => 'public'], + 'inherited_from' => 'snmp-template', + ]; + + $result = DictionaryItem::mergeChildValues($propertyItems, 'fixed-dictionary', $values); + + $this->assertSame('public', $result['community']['inherited']); + $this->assertSame('snmp-template', $result['community']['inherited_from']); + } + + public function testMergeChildValuesLeavesChildrenWithoutAMatchUntouched(): void + { + $propertyItems = [ + ['key_name' => 'team', 'value_type' => 'string'], + ]; + $values = ['value' => ['some_other_key' => 'ops']]; + + $result = DictionaryItem::mergeChildValues($propertyItems, 'fixed-dictionary', $values); + + $this->assertArrayNotHasKey('value', $result['team']); + } + + public function testMergeChildValuesStampsTheParentTypeOntoEveryChild(): void + { + $propertyItems = [ + ['key_name' => 'threshold', 'value_type' => 'number'], + ['key_name' => 'token', 'value_type' => 'sensitive'], + ]; + + $result = DictionaryItem::mergeChildValues($propertyItems, 'dynamic-dictionary', []); + + $this->assertSame('dynamic-dictionary', $result['threshold']['parent_type']); + $this->assertSame('dynamic-dictionary', $result['token']['parent_type']); + } + + public function testMergeChildValuesKeysTheResultByKeyNameRatherThanPosition(): void + { + $propertyItems = [ + ['key_name' => 'second', 'value_type' => 'string'], + ['key_name' => 'first', 'value_type' => 'string'], + ]; + + $result = DictionaryItem::mergeChildValues($propertyItems, 'fixed-dictionary', []); + + $this->assertSame(['second', 'first'], array_keys($result)); + } + + public function testNestedSensitiveFieldPreservesExistingValueWhenLeftUnchanged(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // assemble() must pass the parent's own value down to fetchChildrenItems(), so + // each child gets its own current value too. Without that, a nested sensitive + // field has nothing to fall back on when it comes back as the DUMMYPASSWORD + // placeholder. + $dictionaryItem = $this->buildSnmpV3DictionaryItem(); + + $authPassword = $this->findNestedItem($dictionaryItem, 'auth_password'); + $authPassword->getElement('var')->setValue(SensitiveElement::DUMMYPASSWORD); + + $result = $dictionaryItem->getItem(); + + $this->assertSame('s3cr3t-auth-pass', $result['value']['auth_password']); + } + + public function testNestedSensitiveFieldClearsExistingValueWhenExplicitlyEmptied(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildSnmpV3DictionaryItem(); + + $authPassword = $this->findNestedItem($dictionaryItem, 'auth_password'); + $authPassword->getElement('var')->setValue(''); + + $result = $dictionaryItem->getItem(); + + $this->assertSame('', $result['value']['auth_password']); + } + + /** + * Build a fixed-dictionary DictionaryItem ("snmp_v3") with a string child + * ("username") and a sensitive child ("auth_password"), backed by real + * director_property rows, mirroring what the controller constructs for an + * existing object. + */ + private function buildSnmpV3DictionaryItem(): DictionaryItem + { + $db = $this->getDb(); + $parentUuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'snmp_v3'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-dictionary', + 'label' => 'SNMP v3', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'username', + 'parent_uuid' => $parentUuidBytes, + 'value_type' => 'string', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'auth_password', + 'parent_uuid' => $parentUuidBytes, + 'value_type' => 'sensitive', + ], $db)->store(); + + $dictionaryItem = new DictionaryItem('0', [ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-dictionary', + 'label' => 'SNMP v3', + 'value' => [ + 'username' => 'monitoring', + 'auth_password' => 's3cr3t-auth-pass', + ], + ]); + $dictionaryItem->ensureAssembled(); + + return $dictionaryItem; + } + + /** + * Build a required 'timezone' string DictionaryItem that already has an inherited + * value ("Europe/Vienna" from "base-template"), backed by a real director_property + * row with no children. + */ + private function buildRequiredScalarDictionaryItemWithInheritedValue(): DictionaryItem + { + $db = $this->getDb(); + $uuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'timezone'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $uuidBytes, + 'key_name' => $keyName, + 'value_type' => 'string', + 'label' => 'Timezone', + ], $db)->store(); + + $property = [ + 'uuid' => $uuidBytes, + 'key_name' => $keyName, + 'value_type' => 'string', + 'label' => 'Timezone', + 'required' => true, + 'inherited' => 'Europe/Vienna', + 'inherited_from' => 'base-template', + ]; + + $item = new DictionaryItem('0', $property); + $item->populate(DictionaryItem::prepare($property)); + $item->ensureAssembled(); + + return $item; + } + + /** + * Build a required fixed-array DictionaryItem ("server_settings") with a string, a + * number and a bool slot, none of them populated, nothing inherited either. + */ + private function buildRequiredServerSettingsDictionaryItem(): DictionaryItem + { + $db = $this->getDb(); + $parentUuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'server_settings'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-array', + 'label' => 'Server Settings', + ], $db)->store(); + + foreach (['0' => 'string', '1' => 'number', '2' => 'bool'] as $position => $valueType) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $position, + 'parent_uuid' => $parentUuidBytes, + 'value_type' => $valueType, + ], $db)->store(); + } + + return new DictionaryItem('0', [ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-array', + 'label' => 'Server Settings', + 'required' => true, + ]); + } + + /** + * Build a fixed-array DictionaryItem ("disk_mirrors") with 3 string slots + * + * With no $localValue it is purely inherited. With $localValue given it already + * has its own local override, a sparse array fills only some of its slots. + */ + private function buildDiskMirrorsDictionaryItem(?array $localValue = null, bool $required = false): DictionaryItem + { + $db = $this->getDb(); + $parentUuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'disk_mirrors'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-array', + 'label' => 'Disk Mirrors', + ], $db)->store(); + + foreach (['0', '1', '2'] as $position) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $position, + 'parent_uuid' => $parentUuidBytes, + 'value_type' => 'string', + ], $db)->store(); + } + + $property = [ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-array', + 'label' => 'Disk Mirrors', + 'required' => $required, + ]; + + if ($localValue !== null) { + $property['value'] = $localValue; + } else { + $property['inherited'] = [ + '0' => 'mirror1.example.com', + '1' => 'mirror2.example.com', + '2' => 'mirror3.example.com', + ]; + $property['inherited_from'] = 'storage-template'; + } + + $preparedValues = DictionaryItem::prepare($property); + + $dictionaryItem = new DictionaryItem('0', $property); + $dictionaryItem->populate($preparedValues); + $dictionaryItem->ensureAssembled(); + + return $dictionaryItem; + } + + /** + * Find the nested DictionaryItem for the given key name inside a fixed-dictionary/ + * fixed-array DictionaryItem's 'var' Dictionary. + */ + private function findNestedItem(DictionaryItem $parent, string $keyName): DictionaryItem + { + /** @var Dictionary $dictionary */ + $dictionary = $parent->getElement('var'); + foreach ($dictionary->ensureAssembled()->getElements() as $element) { + if ( + $element instanceof DictionaryItem + && $element->ensureAssembled()->getElement('name')->getValue() === $keyName + ) { + return $element; + } + } + + $this->fail("No nested item named '$keyName' found"); + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $dba = $this->getDb()->getDbAdapter(); + foreach ($this->createdKeyNames as $keyName) { + $rootUuids = $dba->fetchCol( + $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', $keyName) + ); + $uuidsByDepth = [array_map([DbUtil::class, 'binaryResult'], $rootUuids)]; + + // Go level by level, since parent_uuid has no cascading delete and some + // tests build properties more than one level deep. + while (! empty(end($uuidsByDepth))) { + $childUuids = $dba->fetchCol( + $dba->select() + ->from('director_property', ['uuid']) + ->where('parent_uuid IN (?)', DbUtil::quoteBinaryCompat(end($uuidsByDepth), $dba)) + ); + $uuidsByDepth[] = array_map([DbUtil::class, 'binaryResult'], $childUuids); + } + + foreach (array_reverse($uuidsByDepth) as $uuids) { + if (! empty($uuids)) { + $dba->delete( + 'director_property', + $dba->quoteInto('uuid IN (?)', DbUtil::quoteBinaryCompat($uuids, $dba)) + ); + } + } + } + } + + parent::tearDown(); + } +} diff --git a/test/php/library/Director/Form/DictionaryElements/NestedDictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/NestedDictionaryItemTest.php new file mode 100644 index 000000000..2d5b3436b --- /dev/null +++ b/test/php/library/Director/Form/DictionaryElements/NestedDictionaryItemTest.php @@ -0,0 +1,48 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form\DictionaryElements; + +use Icinga\Module\Director\Forms\DictionaryElements\NestedDictionaryItem; +use PHPUnit\Framework\TestCase; + +class NestedDictionaryItemTest extends TestCase +{ + public function testPreparePreservesIntegerZeroValue(): void + { + $nestedItems = [ + ['key_name' => 'warn_threshold', 'label' => 'Warning Threshold', 'value_type' => 'number'], + ]; + $property = ['key' => 'disk_root', 'warn_threshold' => 0]; + + $result = NestedDictionaryItem::prepare($nestedItems, $property); + + $this->assertSame(0, $result['var'][0]['var']); + } + + public function testPreparePreservesFalseValue(): void + { + $nestedItems = [ + ['key_name' => 'monitoring_enabled', 'label' => 'Monitoring Enabled', 'value_type' => 'bool'], + ]; + $property = ['key' => 'disk_root', 'monitoring_enabled' => false]; + + $result = NestedDictionaryItem::prepare($nestedItems, $property); + + $this->assertSame(false, $result['var'][0]['var']); + } + + public function testPrepareStillOmitsTrulyUnsetValue(): void + { + $nestedItems = [ + ['key_name' => 'warn_threshold', 'label' => 'Warning Threshold', 'value_type' => 'number'], + ]; + $property = ['key' => 'disk_root']; + + $result = NestedDictionaryItem::prepare($nestedItems, $property); + + $this->assertSame('', $result['var'][0]['var']); + } +} diff --git a/test/php/library/Director/Form/Lib/TestableCustomVariableForm.php b/test/php/library/Director/Form/Lib/TestableCustomVariableForm.php new file mode 100644 index 000000000..149a98fbe --- /dev/null +++ b/test/php/library/Director/Form/Lib/TestableCustomVariableForm.php @@ -0,0 +1,31 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form\Lib; + +use Icinga\Module\Director\Forms\CustomVariableForm; + +/** + * Test-only subclass that bypasses the CSRF/session requirement in assemble() + * and allows injecting form values directly without submitting a request. + */ +class TestableCustomVariableForm extends CustomVariableForm +{ + private array $testValues = []; + + public function setTestValues(array $values): void + { + $this->testValues = $values; + } + + public function getValues(): array + { + return $this->testValues; + } + + protected function assemble(): void + { + } +} diff --git a/test/php/library/Director/Form/Lib/TestableDictionaryItem.php b/test/php/library/Director/Form/Lib/TestableDictionaryItem.php new file mode 100644 index 000000000..78270a934 --- /dev/null +++ b/test/php/library/Director/Form/Lib/TestableDictionaryItem.php @@ -0,0 +1,43 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form\Lib; + +use Icinga\Module\Director\Forms\DictionaryElements\DictionaryItem; +use Icinga\Module\Director\Web\Form\Element\SensitiveElement; + +/** + * Test adapter that bypasses the DB-backed assemble() of DictionaryItem, registering + * only the specific elements a getItem() scenario needs. + */ +class TestableDictionaryItem extends DictionaryItem +{ + private array $testConfig = []; + + public function setTestConfig(array $config): void + { + $this->testConfig = $config; + } + + protected function assemble(): void + { + $this->addElement('hidden', 'name', ['value' => $this->testConfig['name'] ?? '']); + $this->addElement('hidden', 'type', ['value' => $this->testConfig['type'] ?? '']); + $this->addElement('hidden', 'parent_type', ['value' => $this->testConfig['parent_type'] ?? '']); + $this->addElement('hidden', 'inherited', ['value' => $this->testConfig['inherited'] ?? '']); + $this->addElement('hidden', 'inherited_from', ['value' => $this->testConfig['inherited_from'] ?? '']); + + $varOptions = []; + if (isset($this->testConfig['var'])) { + $varOptions['value'] = $this->testConfig['var']; + } + + if (($this->testConfig['type'] ?? '') === 'sensitive') { + $this->addElement(new SensitiveElement('var', $varOptions)); + } else { + $this->addElement('password', 'var', $varOptions); + } + } +} diff --git a/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php index 10cd644cb..2a9ab3c49 100644 --- a/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php +++ b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php @@ -53,10 +53,10 @@ public function testCorrectlyIdentifiesReservedWords() public function testWhetherDictionaryRendersCorrectly() { $dict = (object) [ - 'key1' => 'bla', - 'include' => 'reserved', - 'spe cial' => 'value', - '0' => 'numeric', + 'address' => '192.0.2.10', + 'include' => 'reserved', + 'on call' => 'contact', + '0' => 'numeric', ]; $this->assertEquals( c::renderDictionary($dict), @@ -71,9 +71,9 @@ protected function loadRendered($name) public function testRenderStringIsCorrectlyRendered() { - $this->assertEquals(c::renderString('val1\\\val2'), '"val1\\\\\\\\val2"'); - $this->assertEquals(c::renderString('"val1"'), '"\"val1\""'); - $this->assertEquals(c::renderString('\$val\$'), '"\\\\$val\\\\$"'); + $this->assertEquals(c::renderString('C:\Program Files\NSClient++'), '"C:\\\\Program Files\\\\NSClient++"'); + $this->assertEquals(c::renderString('"check_disk"'), '"\"check_disk\""'); + $this->assertEquals(c::renderString('\$ORACLE_SID\$'), '"\\\\$ORACLE_SID\\\\$"'); $this->assertEquals(c::renderString('\t'), '"\\\\t"'); $this->assertEquals(c::renderString('\r'), '"\\\\r"'); $this->assertEquals(c::renderString('\n'), '"\\\\n"'); @@ -85,45 +85,132 @@ public function testMacrosAreDetected() $this->assertFalse(c::stringHasMacro('$$vars$')); $this->assertFalse(c::stringHasMacro('$$')); $this->assertTrue(c::stringHasMacro('$vars$$')); - $this->assertTrue(c::stringHasMacro('$multiple$$vars.nested.name$$vars$ is here')); + $this->assertTrue(c::stringHasMacro('$address$$vars.nested.name$$vars$ is here')); $this->assertTrue(c::stringHasMacro('some $vars.nested.name$ is here')); $this->assertTrue(c::stringHasMacro('some $vars.nested.name$$vars.even.more$')); - $this->assertTrue(c::stringHasMacro('$vars.nested.name$$a$$$$not$')); + $this->assertTrue(c::stringHasMacro('$vars.nested.name$$ip$$$$sid$')); $this->assertTrue(c::stringHasMacro('MSSQL$$$config$')); $this->assertTrue(c::stringHasMacro('MSSQL$$$config$', 'config')); - $this->assertTrue(c::stringHasMacro('MSSQL$$$nix$ and $config$', 'config')); - $this->assertFalse(c::stringHasMacro('MSSQL$$$nix$config$ and $$', 'config')); - $this->assertFalse(c::stringHasMacro('MSSQL$$$nix$ and $$config$', 'config')); + $this->assertTrue(c::stringHasMacro('MSSQL$$$linux$ and $config$', 'config')); + $this->assertFalse(c::stringHasMacro('MSSQL$$$linux$config$ and $$', 'config')); + $this->assertFalse(c::stringHasMacro('MSSQL$$$linux$ and $$config$', 'config')); $this->assertFalse(c::stringHasMacro('MSSQL$$$config$', 'conf')); } public function testRenderStringWithVariables() { - $this->assertEquals('"Before " + var', c::renderStringWithVariables('Before $var$')); - $this->assertEquals(c::renderStringWithVariables('$var$ After'), 'var + " After"'); - $this->assertEquals(c::renderStringWithVariables('$var$'), 'var'); - $this->assertEquals(c::renderStringWithVariables('$$var$$'), '"$$var$$"'); - $this->assertEquals(c::renderStringWithVariables('Before $$var$$ After'), '"Before $$var$$ After"'); + $this->assertEquals('"Before " + address', c::renderStringWithVariables('Before $address$')); + $this->assertEquals(c::renderStringWithVariables('$address$ After'), 'address + " After"'); + $this->assertEquals(c::renderStringWithVariables('$address$'), 'address'); + $this->assertEquals(c::renderStringWithVariables('$$address$$'), '"$$address$$"'); + $this->assertEquals(c::renderStringWithVariables('Before $$address$$ After'), '"Before $$address$$ After"'); $this->assertEquals( - '"Before " + name1 + " " + name2 + " After"', - c::renderStringWithVariables('Before $name1$ $name2$ After') + '"Before " + display_name + " " + check_command + " After"', + c::renderStringWithVariables('Before $display_name$ $check_command$ After') ); } public function testRenderStringWithVariablesX() { $this->assertEquals( - '"Before " + var1 + " " + var2 + " After"', - c::renderStringWithVariables('Before $var1$ $var2$ After') + '"Before " + address + " " + port + " After"', + c::renderStringWithVariables('Before $address$ $port$ After') ); $this->assertEquals( 'host.vars.custom', c::renderStringWithVariables('$host.vars.custom$') ); - $this->assertEquals('"$var\"$"', c::renderStringWithVariables('$var"$')); + $this->assertEquals('"$address\"$"', c::renderStringWithVariables('$address"$')); $this->assertEquals( - '"\\\\tI am\\\\rrendering\\\\nproperly\\\\fand I " + support + " \"multiple\" " + variables + "\\\\$"', - c::renderStringWithVariables('\tI am\rrendering\nproperly\fand I $support$ "multiple" $variables$\$') + '"\\\\tCPU load\\\\ris\\\\nabove\\\\fwarning on " + address + " \"threshold\" " + display_name + "\\\\$"', + c::renderStringWithVariables('\tCPU load\ris\nabove\fwarning on $address$ "threshold" $display_name$\$') ); } + + public function testIsValidMacroNameWithNoWhitelist() + { + // Valid names: letter/underscore start, letters/digits/underscores/dots, no trailing dot + $this->assertTrue(c::isValidMacroName('host.vars.custom')); + $this->assertTrue(c::isValidMacroName('value.path')); + $this->assertTrue(c::isValidMacroName('check_interval')); + $this->assertTrue(c::isValidMacroName('ab')); + + // Single character is invalid: the pattern requires at least 2 characters + $this->assertFalse(c::isValidMacroName('a')); + + // Trailing dot is explicitly rejected + $this->assertFalse(c::isValidMacroName('value.')); + $this->assertFalse(c::isValidMacroName('host.')); + + // Starts with a digit: not matched by [A-z_] + $this->assertFalse(c::isValidMacroName('1invalid')); + + // Empty string + $this->assertFalse(c::isValidMacroName('')); + } + + public function testIsValidMacroNameExactWhitelistMatch() + { + $this->assertTrue(c::isValidMacroName('value.path', ['value.path'])); + $this->assertTrue(c::isValidMacroName('value.mount_point', ['value.path', 'value.mount_point'])); + } + + public function testIsValidMacroNameWildcardWhitelistMatch() + { + $this->assertTrue(c::isValidMacroName('value.mount_point', ['value.*'])); + $this->assertTrue(c::isValidMacroName('value.warn', ['value.*'])); + $this->assertTrue(c::isValidMacroName('host.address', ['host.*', 'value.*'])); + } + + public function testIsValidMacroNameWhitelistNoMatch() + { + // Name not in whitelist and not matching any wildcard returns false + $this->assertFalse(c::isValidMacroName('host.vars.custom', ['value.*'])); + $this->assertFalse(c::isValidMacroName('host.vars.custom', ['check_command'])); + } + + public function testIsValidMacroNameEmptyWhitelistReturnsFalse() + { + // When a non-null whitelist is provided, only whitelist matches count — + // an empty whitelist means nothing is permitted + $this->assertFalse(c::isValidMacroName('host.vars.custom', [])); + $this->assertFalse(c::isValidMacroName('value.path', [])); + } + + public function testIsValidMacroNameWhitelistOverridesPatternCheck() + { + // A name that does not match the base macro pattern is still valid when + // explicitly listed in the whitelist + $this->assertTrue(c::isValidMacroName('a', ['a'])); + $this->assertTrue(c::isValidMacroName('value.', ['value.'])); + } + + public function testIsValidMacroNameWildcardDoesNotBypassSyntaxCheck(): void + { + $this->assertFalse(c::isValidMacroName('host.name) { throw "injected"', ['host.*'])); + $this->assertFalse(c::isValidMacroName('value[0 OR 1=1]', ['value[*]'])); + $this->assertFalse(c::isValidMacroName('value[0].sub) { evil', ['value[*].*'])); + $this->assertFalse(c::isValidMacroName('value["on call\\") { evil', ['value[*]'])); + } + + public function testIsValidMacroNameWildcardStillMatchesArrayIndexAndDictionaryForms(): void + { + $whiteList = ['value', 'host.*', 'value[*]', 'value[*].*']; + + $this->assertTrue(c::isValidMacroName('host.vars.custom', $whiteList)); + $this->assertTrue(c::isValidMacroName('value[0]', $whiteList)); + $this->assertTrue(c::isValidMacroName('value[12]', $whiteList)); + $this->assertTrue(c::isValidMacroName('value[0].sub_key', $whiteList)); + $this->assertFalse(c::isValidMacroName('value[]', $whiteList)); + $this->assertFalse(c::isValidMacroName('value[abc]', $whiteList)); + } + + public function testIsValidMacroNameWildcardMatchesQuotedDictionaryKeyWithSpace(): void + { + $whiteList = ['value', 'host.*', 'value[*]', 'value[*].*']; + + $this->assertTrue(c::isValidMacroName('value["on call contact"]', $whiteList)); + $this->assertTrue(c::isValidMacroName('value["on call contact"].email', $whiteList)); + $this->assertFalse(c::isValidMacroName('value["on call contact]', $whiteList)); + } } diff --git a/test/php/library/Director/IcingaConfig/rendered/dict1.out b/test/php/library/Director/IcingaConfig/rendered/dict1.out index 9f4e6bf98..d3b3fe226 100644 --- a/test/php/library/Director/IcingaConfig/rendered/dict1.out +++ b/test/php/library/Director/IcingaConfig/rendered/dict1.out @@ -1,6 +1,6 @@ { "0" = numeric + address = 192.0.2.10 @include = reserved - key1 = bla - "spe cial" = value + "on call" = contact } diff --git a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php new file mode 100644 index 000000000..7972f2ee4 --- /dev/null +++ b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php @@ -0,0 +1,529 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Objects; + +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\DirectorObject\Automation\BasketSnapshot; +use Icinga\Module\Director\DirectorObject\Automation\BasketSnapshotCustomVariableResolver; +use Icinga\Module\Director\Objects\DirectorProperty; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Test\BaseTestCase; +use Ramsey\Uuid\Uuid; + +/** + * Integration tests for BasketSnapshot round-trip with DirectorProperty (custom variables). + * + * Scenario: a host template "linux-server" carries a disk_checks dynamic-dictionary property. + * Snapshot, wipe, restore, and verify the system returns to its original state. + */ +class BasketSnapshotCustomVariableTest extends BaseTestCase +{ + private const PREFIX = '___TEST___'; + + private const TEMPLATE_NAME = self::PREFIX . 'linux-server'; + private const PROP_KEY_NAME = self::PREFIX . 'disk_checks_bk'; + + public function testSnapshotIncludesCustomVariableSection(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + [$host, $property] = $this->createTemplateWithProperty($db); + + $json = $this->buildSnapshotJson($host, $property, $db); + $decoded = json_decode($json, true); + + $this->assertArrayHasKey( + 'CustomVariable', + $decoded, + 'Basket JSON must contain a CustomVariable section' + ); + } + + public function testRestoreCreatesDirectorProperty(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + [$host, $property] = $this->createTemplateWithProperty($db); + $json = $this->buildSnapshotJson($host, $property, $db); + $propUuid = Uuid::fromBytes($property->get('uuid')); + + $this->wipeTemplateAndProperty($host, $property, $db); + + BasketSnapshot::restoreJson($json, $db); + + $restored = DirectorProperty::loadWithUniqueId($propUuid, $db); + $this->assertNotNull($restored, 'director_property row must be created by restore'); + $this->assertEquals(self::PROP_KEY_NAME, $restored->get('key_name')); + $this->assertEquals('dynamic-dictionary', $restored->get('value_type')); + } + + public function testRestoreBindsPropertyToTemplate(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + [$host, $property] = $this->createTemplateWithProperty($db); + $json = $this->buildSnapshotJson($host, $property, $db); + + $this->wipeTemplateAndProperty($host, $property, $db); + + BasketSnapshot::restoreJson($json, $db); + + $restoredHost = IcingaHost::load(self::TEMPLATE_NAME, $db); + $restoredProp = DirectorProperty::loadWithUniqueId( + Uuid::fromBytes($property->get('uuid')), + $db + ); + + $dba = $db->getDbAdapter(); + $count = $dba->fetchOne( + $dba->select() + ->from('icinga_host_property', ['cnt' => 'COUNT(*)']) + ->where( + 'host_uuid = ?', + DbUtil::quoteBinaryCompat(DbUtil::binaryResult($restoredHost->get('uuid')), $dba) + ) + ->where( + 'property_uuid = ?', + DbUtil::quoteBinaryCompat(DbUtil::binaryResult($restoredProp->get('uuid')), $dba) + ) + ); + + $this->assertEquals(1, (int) $count, 'icinga_host_property binding must be restored'); + } + + public function testRestoreUpdatesRequiredFlagOnExistingAttachment(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + [$host, $property] = $this->createTemplateWithProperty($db); + + $exporter = new Exporter($db); + $exportedHost = $exporter->export($host); + $propertyUuidString = Uuid::fromBytes($property->get('uuid'))->toString(); + foreach ($exportedHost->customVariables as $customVariable) { + if ($customVariable->property_uuid === $propertyUuidString) { + // createTemplateWithProperty() attaches this property without setting + // 'required', so it defaults to 'n' - the snapshot below says it must + // now be required. + $customVariable->required = 'y'; + } + } + + $json = json_encode([ + 'HostTemplate' => [self::TEMPLATE_NAME => $exportedHost], + 'CustomVariable' => [$propertyUuidString => $property->export()], + ]); + + // Restoring over the still-existing attachment (no wipe here) must update + // 'required' in place, not skip it just because the link row already exists. + BasketSnapshot::restoreJson($json, $db); + + $dba = $db->getDbAdapter(); + $required = $dba->fetchOne( + $dba->select() + ->from('icinga_host_property', ['required']) + ->where('host_uuid = ?', DbUtil::quoteBinaryCompat(DbUtil::binaryResult($host->get('uuid')), $dba)) + ->where( + 'property_uuid = ?', + DbUtil::quoteBinaryCompat(DbUtil::binaryResult($property->get('uuid')), $dba) + ) + ); + + $this->assertEquals( + 'y', + $required, + 'An existing attachment must have its required flag updated on restore, not left stale' + ); + } + + public function testRestoreChildItemsForDictionary(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + [$host, $property] = $this->createTemplateWithProperty($db); + + // Add child fields to the dictionary + foreach (['mount_point', 'warn', 'crit'] as $field) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $field, + 'parent_uuid' => $property->get('uuid'), + 'value_type' => 'string', + ], $db)->store(); + } + + // Re-load to pick up the fresh items + $property = DirectorProperty::loadWithUniqueId( + Uuid::fromBytes($property->get('uuid')), + $db + ); + + $json = $this->buildSnapshotJson($host, $property, $db); + + $this->wipeTemplateAndProperty($host, $property, $db); + + BasketSnapshot::restoreJson($json, $db); + + $restored = DirectorProperty::loadWithUniqueId( + Uuid::fromBytes($property->get('uuid')), + $db + ); + $childKeys = array_map( + fn($c) => $c->get('key_name'), + $restored->fetchItemsFromDb() + ); + sort($childKeys); + + $this->assertEquals( + ['crit', 'mount_point', 'warn'], + $childKeys, + 'All child items must be restored for the dictionary property' + ); + } + + public function testRestoreRemovesChildItemsNotInSnapshot(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + [$host, $property] = $this->createTemplateWithProperty($db); + + foreach (['mount_point', 'warn'] as $field) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $field, + 'parent_uuid' => $property->get('uuid'), + 'value_type' => 'string', + ], $db)->store(); + } + + $property = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($property->get('uuid')), $db); + $json = $this->buildSnapshotJson($host, $property, $db); + + // A field added on the target after the snapshot was taken must be removed + // again on restore, not left sitting alongside the snapshot's own children. + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'crit', + 'parent_uuid' => $property->get('uuid'), + 'value_type' => 'string', + ], $db)->store(); + + BasketSnapshot::restoreJson($json, $db); + + $restored = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($property->get('uuid')), $db); + $childKeys = array_map(fn($c) => $c->get('key_name'), $restored->fetchItemsFromDb()); + sort($childKeys); + + $this->assertEquals( + ['mount_point', 'warn'], + $childKeys, + 'A child that only exists on the target, not in the snapshot, must be removed on restore' + ); + } + + public function testRestoreStampsPropertyUuidOnVarTable(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + [$host, $property] = $this->createTemplateWithProperty($db); + + $host->vars()->set(self::PROP_KEY_NAME, (object) ['mount_point' => '/', 'warn' => 80]); + $host->store(); + + $json = $this->buildSnapshotJson($host, $property, $db); + + $this->wipeTemplateAndProperty($host, $property, $db); + + BasketSnapshot::restoreJson($json, $db); + + $restoredHost = IcingaHost::load(self::TEMPLATE_NAME, $db); + $restoredProp = DirectorProperty::loadWithUniqueId( + Uuid::fromBytes($property->get('uuid')), + $db + ); + + $dba = $db->getDbAdapter(); + $storedUuid = $dba->fetchOne( + $dba->select() + ->from('icinga_host_var', ['property_uuid']) + ->where( + 'host_id = ?', + $restoredHost->get('id') + ) + ->where('varname = ?', self::PROP_KEY_NAME) + ); + + $this->assertEquals( + DbUtil::binaryResult($restoredProp->get('uuid')), + DbUtil::binaryResult($storedUuid), + 'icinga_host_var.property_uuid must be stamped with the restored property uuid' + ); + } + + public function testRestoreCreatesCategoryWhenMissingOnTarget(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + $categoryName = self::PREFIX . 'disk_category'; + + [$host, $property] = $this->createTemplateWithProperty($db); + $property->setCategory($categoryName); + $property->store(); + + $json = $this->buildSnapshotJson($host, $property, $db); + + $this->wipeTemplateAndProperty($host, $property, $db); + // The category has to be gone on the target too, that's the fresh-DB + // case setCategory() used to get wrong. + $dba->delete('director_datafield_category', $dba->quoteInto('category_name = ?', $categoryName)); + + BasketSnapshot::restoreJson($json, $db); + + $restored = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($property->get('uuid')), $db); + $this->assertEquals( + $categoryName, + $restored->getCategoryName(), + 'category_name must survive a restore onto a DB where the category does not exist yet' + ); + + // restoreJson() recreated the property (and its category link), so it must be wiped + // again before the category delete below, or that delete hits director_property_category's + // ON DELETE RESTRICT. + $this->wipeTemplateAndProperty($host, $property, $db); + $dba->delete('director_datafield_category', $dba->quoteInto('category_name = ?', $categoryName)); + } + + public function testRestoreIsIdempotent(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + [$host, $property] = $this->createTemplateWithProperty($db); + $json = $this->buildSnapshotJson($host, $property, $db); + + $this->wipeTemplateAndProperty($host, $property, $db); + + BasketSnapshot::restoreJson($json, $db); + BasketSnapshot::restoreJson($json, $db); + + $dba = $db->getDbAdapter(); + $propCount = $dba->fetchOne( + $dba->select() + ->from('director_property', ['cnt' => 'COUNT(*)']) + ->where('uuid = ?', DbUtil::quoteBinaryCompat(DbUtil::binaryResult($property->get('uuid')), $dba)) + ); + + $this->assertEquals(1, (int) $propCount, 'Restoring twice must not create duplicate properties'); + } + + public function testRelinkBeforeStoreNewPropertiesThrows(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = IcingaHost::create([ + 'object_name' => self::PREFIX . 'resolver-order-host', + 'object_type' => 'template', + ]); + $host->store($db); + + $propertyUuid = Uuid::uuid4()->toString(); + $resolver = new BasketSnapshotCustomVariableResolver( + [ + 'CustomVariable' => [ + $propertyUuid => (object) [ + 'uuid' => $propertyUuid, + 'key_name' => self::PREFIX . 'resolver_order_prop', + 'value_type' => 'string', + 'label' => null, + 'parent_uuid' => null, + 'category' => null, + 'description' => null, + 'items' => [], + ], + ], + ], + $db + ); + + $exportedObject = (object) [ + 'customVariables' => [ + (object) ['property_uuid' => $propertyUuid], + ], + ]; + + try { + $this->expectException(ProgrammingError::class); + $resolver->relinkObjectCustomProperties($host, $exportedObject); + } finally { + $host->delete(); + } + } + + public function testBasketsWithoutCustomPropertiesStillWork(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + // Basket with a host template that has no custom properties key at all + $templateName = self::PREFIX . 'no-props-template'; + $json = json_encode([ + 'HostTemplate' => [ + $templateName => (object) [ + 'object_name' => $templateName, + 'object_type' => 'template', + ] + ] + ]); + + $this->expectNotToPerformAssertions(); + BasketSnapshot::restoreJson($json, $db); + + // Cleanup + if (IcingaHost::exists($templateName, $db)) { + IcingaHost::load($templateName, $db)->delete(); + } + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + if (IcingaHost::exists(self::TEMPLATE_NAME, $db)) { + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + $dba->delete( + 'icinga_host_property', + $dba->quoteInto( + 'host_uuid = ?', + DbUtil::quoteBinaryCompat(DbUtil::binaryResult($host->get('uuid')), $dba) + ) + ); + $host->delete(); + } + + $rows = $dba->fetchAll( + $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', self::PROP_KEY_NAME) + ); + foreach ($rows as $row) { + $dba->delete( + 'director_property', + $dba->quoteInto( + 'parent_uuid = ?', + DbUtil::quoteBinaryCompat(DbUtil::binaryResult($row->uuid), $dba) + ) + ); + } + + $dba->delete('director_property', $dba->quoteInto('key_name = ?', self::PROP_KEY_NAME)); + } + + parent::tearDown(); + } + + /** + * @return array{IcingaHost, DirectorProperty} + */ + private function createTemplateWithProperty($db): array + { + if (IcingaHost::exists(self::TEMPLATE_NAME, $db)) { + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + } else { + $host = IcingaHost::create([ + 'object_name' => self::TEMPLATE_NAME, + 'object_type' => 'template', + ]); + $host->store($db); + } + + $dba = $db->getDbAdapter(); + $dba->delete('director_property', $dba->quoteInto('key_name = ?', self::PROP_KEY_NAME)); + + $property = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => self::PROP_KEY_NAME, + 'value_type' => 'dynamic-dictionary', + 'label' => 'Disk Checks', + ], $db); + $property->store(); + + $dba = $db->getDbAdapter(); + $db->insert('icinga_host_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($property->get('uuid'), $dba), + 'host_uuid' => DbUtil::quoteBinaryCompat($host->get('uuid'), $dba), + ]); + + return [$host, $property]; + } + + private function buildSnapshotJson(IcingaHost $host, DirectorProperty $property, $db): string + { + $exporter = new Exporter($db); + $exportedHost = $exporter->export($host); + + $exportedProperty = $property->export(); + $propertyUuid = Uuid::fromBytes($property->get('uuid'))->toString(); + + $snapshot = [ + 'HostTemplate' => [ + self::TEMPLATE_NAME => $exportedHost, + ], + 'CustomVariable' => [ + $propertyUuid => $exportedProperty, + ], + ]; + + return json_encode($snapshot); + } + + private function wipeTemplateAndProperty(IcingaHost $host, DirectorProperty $property, $db): void + { + $dba = $db->getDbAdapter(); + $quotedHostUuid = DbUtil::quoteBinaryCompat(DbUtil::binaryResult($host->get('uuid')), $dba); + $quotedPropUuid = DbUtil::quoteBinaryCompat(DbUtil::binaryResult($property->get('uuid')), $dba); + + $dba->delete('icinga_host_property', $dba->quoteInto('host_uuid = ?', $quotedHostUuid)); + $dba->delete('director_property', $dba->quoteInto('parent_uuid = ?', $quotedPropUuid)); + $dba->delete('director_property', $dba->quoteInto('uuid = ?', $quotedPropUuid)); + + IcingaHost::load(self::TEMPLATE_NAME, $db)->delete(); + } +} diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php new file mode 100644 index 000000000..6c3dd61e9 --- /dev/null +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -0,0 +1,895 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Objects\DirectorDatalist; +use Icinga\Module\Director\Objects\DirectorProperty; +use Icinga\Module\Director\Test\BaseTestCase; +use InvalidArgumentException; +use Ramsey\Uuid\Uuid; + +/** + * Integration tests for DirectorProperty model + */ +class DirectorPropertyTest extends BaseTestCase +{ + private const PREFIX = '___TEST___'; + + /** @var string[] key_names of root properties created in tests (for tearDown) */ + private array $createdKeyNames = []; + + /** @var string[] list_names of datalists created in tests (for tearDown) */ + private array $createdListNames = []; + + public function testStringPropertyPersistsAndReloads(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $property = $this->makeProperty('env', 'string', 'Environment', $db); + $property->store(); + + $uuid = Uuid::fromBytes($property->get('uuid')); + $loaded = DirectorProperty::loadWithUniqueId($uuid, $db); + + $this->assertNotNull($loaded); + $this->assertEquals(self::PREFIX . 'env', $loaded->get('key_name')); + $this->assertEquals('string', $loaded->get('value_type')); + $this->assertEquals('Environment', $loaded->get('label')); + } + + public function testBoolPropertyPersistsAndReloads(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $property = $this->makeProperty('in_maintenance', 'bool', 'In Maintenance', $db); + $property->store(); + + $uuid = Uuid::fromBytes($property->get('uuid')); + $loaded = DirectorProperty::loadWithUniqueId($uuid, $db); + + $this->assertNotNull($loaded); + $this->assertEquals(self::PREFIX . 'in_maintenance', $loaded->get('key_name')); + $this->assertEquals('bool', $loaded->get('value_type')); + $this->assertEquals('In Maintenance', $loaded->get('label')); + } + + public function testKeyNameUniquenessIsCaseInsensitiveOnBothDatabases(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $lower = $this->makeProperty('case_check', 'string', 'Case Check', $db); + $lower->store(); + + $upper = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => self::PREFIX . 'CASE_CHECK', + 'value_type' => 'string', + ], $db); + + try { + $upper->store(); + $this->fail('Storing a key_name differing only by case must violate the unique constraint'); + } catch (\RuntimeException $e) { + $msg = $e->getMessage(); + $matchMysql = strpos($msg, 'Duplicate entry') !== false; + $matchPostgres = strpos($msg, 'Unique violation') !== false; + + $this->assertTrue( + $matchMysql || $matchPostgres, + 'Exception message does not tell about unique constraint violation: ' . $msg + ); + } + } + + public function testGetDatalistReturnsNullWithoutThrowingWhenNoLinkExistsYet(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + // Created directly via create()->store(), not import(): $datalist is never + // pre-populated, and no director_property_datalist row exists for this uuid yet. + $property = $this->makeProperty('unlinked_datalist', 'datalist-strict', 'Unlinked', $db); + $property->store(); + + $this->assertNull($property->getDatalist()); + } + + public function testDynamicArrayPropertyWithChildItem(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $parent = $this->makeProperty('http_vhosts', 'dynamic-array', 'HTTP Vhosts', $db); + $parent->store(); + + $parentUuid = $parent->get('uuid'); + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '0', + 'parent_uuid' => $parentUuid, + 'value_type' => 'string', + ], $db); + $child->store(); + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $db); + $items = $reloaded->fetchItemsFromDb(); + + $this->assertCount(1, $items); + $this->assertEquals('string', $items[0]->get('value_type')); + } + + public function testFixedDictionaryWithSubfields(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $parent = $this->makeProperty('disk_check', 'fixed-dictionary', 'Disk Check', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + foreach (['warn', 'crit'] as $fieldName) { + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $fieldName, + 'parent_uuid' => $parentUuid, + 'value_type' => 'string', + ], $db); + $child->store(); + } + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $db); + $items = $reloaded->fetchItemsFromDb(); + $childKeys = array_map(fn($c) => $c->get('key_name'), $items); + sort($childKeys); + + $this->assertCount(2, $items); + $this->assertEquals(['crit', 'warn'], $childKeys); + } + + /** + * @dataProvider provideNonNestableTypes + */ + public function testContainerTypesAreRejectedByTheModelWhenNested(string $valueType): void + { + if ($this->skipForMissingDb()) { + return; + } + + // These rules must hold regardless of entry point (form, REST API, CLI migration, + // basket restore), not just because CustomVariableForm's dropdown happens to never + // offer them as a nested option. DirectorProperty::beforeStore() enforces it directly. + $db = $this->getDb(); + $suffix = 'network_config_' . str_replace('-', '_', $valueType); + $parent = $this->makeProperty($suffix, 'fixed-dictionary', 'Network Config', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'interfaces', + 'parent_uuid' => $parentUuid, + 'value_type' => $valueType, + ], $db); + + $this->expectException(InvalidArgumentException::class); + $child->store(); + } + + public function provideNonNestableTypes(): array + { + return [ + 'dynamic-dictionary' => ['dynamic-dictionary'], + 'fixed-dictionary' => ['fixed-dictionary'], + 'fixed-array' => ['fixed-array'], + ]; + } + + /** + * A crafted basket snapshot is the threat model the nesting guard was built for + * (see beforeStore()), so it must be enforced on the import() -> store() path too, + * not only on properties built directly via create(). + */ + public function testContainerTypeNestedViaBasketImportIsRejected(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $parentKeyName = self::PREFIX . 'router_config'; + $this->createdKeyNames[] = $parentKeyName; + + $parentUuid = Uuid::uuid4()->toString(); + $plain = (object) [ + 'uuid' => $parentUuid, + 'key_name' => $parentKeyName, + 'value_type' => 'fixed-dictionary', + 'label' => 'Router Config', + 'parent_uuid' => null, + 'category' => null, + 'description' => null, + 'items' => [ + 'interfaces' => (object) [ + 'uuid' => Uuid::uuid4()->toString(), + 'key_name' => 'interfaces', + 'value_type' => 'fixed-dictionary', + 'label' => null, + 'parent_uuid' => $parentUuid, + 'category' => null, + 'description' => null, + 'items' => [], + ], + ], + ]; + + $imported = DirectorProperty::import($plain, $db); + $imported->store(); + + $this->expectException(InvalidArgumentException::class); + foreach ($imported->fetchItemsFromDb() as $child) { + $child->store(); + } + } + + /** + * @dataProvider provideDynamicArrayNestableParentTypes + */ + public function testDynamicArrayIsAllowedAsAFieldOfOtherContainerTypes(string $parentValueType): void + { + if ($this->skipForMissingDb()) { + return; + } + + // dynamic-array may be used as a field inside a fixed-array, fixed-dictionary or + // dynamic-dictionary; it is only barred from nesting inside itself. + $db = $this->getDb(); + $suffix = 'network_config_' . str_replace('-', '_', $parentValueType); + $parent = $this->makeProperty($suffix, $parentValueType, 'Network Config', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'dns_servers', + 'parent_uuid' => $parentUuid, + 'value_type' => 'dynamic-array', + ], $db); + $child->store(); + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $db); + $items = $reloaded->fetchItemsFromDb(); + $this->assertCount(1, $items); + $this->assertEquals('dynamic-array', $items[0]->get('value_type')); + } + + public function provideDynamicArrayNestableParentTypes(): array + { + return [ + 'fixed-array' => ['fixed-array'], + 'fixed-dictionary' => ['fixed-dictionary'], + 'dynamic-dictionary' => ['dynamic-dictionary'], + ]; + } + + public function testDynamicArrayIsAllowedAsTheItemTypeOfADatalist(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // A datalist's item type may be 'dynamic-array' to declare that it accepts a list of + // values instead of a single one (see CustomVariableValueValidator). + $db = $this->getDb(); + $parent = $this->makeProperty('allowed_regions', 'datalist-strict', 'Allowed Regions', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + $itemType = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '0', + 'parent_uuid' => $parentUuid, + 'value_type' => 'dynamic-array', + ], $db); + + $itemType->store(); + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $db); + $items = $reloaded->fetchItemsFromDb(); + $this->assertCount(1, $items); + $this->assertEquals('dynamic-array', $items[0]->get('value_type')); + } + + public function testDynamicArrayCannotBeNestedInsideAnotherDynamicArray(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $parent = $this->makeProperty('backup_ports', 'dynamic-array', 'Backup Ports', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '0', + 'parent_uuid' => $parentUuid, + 'value_type' => 'dynamic-array', + ], $db); + + $this->expectException(InvalidArgumentException::class); + $child->store(); + } + + public function testSensitiveIsAllowedAsAFieldOfAFixedDictionary(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // Only a dynamic-array is barred from holding a sensitive item, since it renders + // every entry in the clear; a fixed-dictionary field has no such restriction. + $db = $this->getDb(); + $parent = $this->makeProperty('snmp_settings', 'fixed-dictionary', 'SNMP Settings', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'community_string', + 'parent_uuid' => $parentUuid, + 'value_type' => 'sensitive', + ], $db); + $child->store(); + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $db); + $items = $reloaded->fetchItemsFromDb(); + $this->assertCount(1, $items); + $this->assertEquals('sensitive', $items[0]->get('value_type')); + } + + public function testSensitiveCannotBeNestedInsideADynamicArray(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // A dynamic-array renders every entry in the clear, so a sensitive item + // would never actually stay hidden. + $db = $this->getDb(); + $parent = $this->makeProperty('community_strings', 'dynamic-array', 'Community Strings', $db); + $parent->store(); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '0', + 'parent_uuid' => $parent->get('uuid'), + 'value_type' => 'sensitive', + ], $db); + + $this->expectException(InvalidArgumentException::class); + $child->store(); + } + + /** + * @dataProvider provideDatalistTypes + */ + public function testSensitiveCannotBeNestedInsideADatalist(string $datalistType): void + { + if ($this->skipForMissingDb()) { + return; + } + + // A datalist renders every entry in the clear too, same reasoning as a dynamic-array. + $db = $this->getDb(); + $suffix = 'community_strings_' . str_replace('-', '_', $datalistType); + $parent = $this->makeProperty($suffix, $datalistType, 'Community Strings', $db); + $parent->store(); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '0', + 'parent_uuid' => $parent->get('uuid'), + 'value_type' => 'sensitive', + ], $db); + + $this->expectException(InvalidArgumentException::class); + $child->store(); + } + + public function testSwitchingToDynamicArrayIsRejectedWhenASensitiveChildStillExists(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // A dynamic-dictionary is a safe home for a sensitive child, but flipping that same + // property to dynamic-array afterwards is not, that type shows everything in the clear. + $db = $this->getDb(); + $parent = $this->makeProperty('secrets_holder', 'dynamic-dictionary', 'Secrets Holder', $db); + $parent->store(); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'password', + 'parent_uuid' => $parent->get('uuid'), + 'value_type' => 'sensitive', + ], $db); + $child->store(); + + $parent->set('value_type', 'dynamic-array'); + $this->expectException(InvalidArgumentException::class); + $parent->store(); + } + + /** + * @dataProvider provideDatalistTypes + */ + public function testSwitchingToDatalistIsRejectedWhenASensitiveChildStillExists(string $datalistType): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $suffix = 'secrets_holder_' . str_replace('-', '_', $datalistType); + $parent = $this->makeProperty($suffix, 'dynamic-dictionary', 'Secrets Holder', $db); + $parent->store(); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'password', + 'parent_uuid' => $parent->get('uuid'), + 'value_type' => 'sensitive', + ], $db); + $child->store(); + + $parent->set('value_type', $datalistType); + $this->expectException(InvalidArgumentException::class); + $parent->store(); + } + + public function testSwitchingToDynamicArrayIsAllowedWithoutASensitiveChild(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // Switching type stays possible as long as nothing sensitive would end up exposed. + $db = $this->getDb(); + $parent = $this->makeProperty('plain_holder', 'dynamic-dictionary', 'Plain Holder', $db); + $parent->store(); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'note', + 'parent_uuid' => $parent->get('uuid'), + 'value_type' => 'string', + ], $db); + $child->store(); + + $parent->set('value_type', 'dynamic-array'); + $parent->store(); + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parent->get('uuid')), $db); + $this->assertEquals('dynamic-array', $reloaded->get('value_type')); + } + + public function provideDatalistTypes(): array + { + return [ + 'datalist-strict' => ['datalist-strict'], + 'datalist-non-strict' => ['datalist-non-strict'], + ]; + } + + public function testDatalistStrictAssociatesDatalist(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $listName = self::PREFIX . 'environments'; + $this->makeDatalist($listName, $db)->store(); + $property = $this->importPropertyWithDatalist('env_choices', 'datalist-strict', 'Env Choices', $listName, $db); + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($property->get('uuid')), $db); + $linked = $reloaded->getDatalist(); + + $this->assertNotNull($linked); + $this->assertEquals($listName, $linked->get('list_name')); + } + + public function testDatalistStrictExportIncludesDatalistName(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $listName = self::PREFIX . 'export_list'; + $this->makeDatalist($listName, $db)->store(); + $property = $this->importPropertyWithDatalist('env_export', 'datalist-strict', 'Env Export', $listName, $db); + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($property->get('uuid')), $db); + $exported = $reloaded->export(); + + $this->assertTrue(property_exists($exported, 'datalist')); + $this->assertEquals($listName, $exported->datalist); + } + + public function testReSavingDatalistPropertyLoadedFromDbDoesNotBreakLink(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $listName = self::PREFIX . 'resave_list'; + $this->makeDatalist($listName, $db)->store(); + $property = $this->importPropertyWithDatalist('env_resave', 'datalist-strict', 'Env Resave', $listName, $db); + + // Load the way ordinary (non-import) code paths do, e.g. a plain edit or + // BasketSnapshotCustomVariableResolver::restoreCustomPropertyItems(). $datalist is NOT + // pre-populated on this instance, unlike objects returned by DirectorProperty::import(). + $loaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($property->get('uuid')), $db); + $loaded->set('label', 'Env Resave Updated'); + $loaded->store(); + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($property->get('uuid')), $db); + $this->assertNotNull( + $reloaded->getDatalist(), + 'Re-saving a datalist property loaded without import() must not drop its datalist link' + ); + $this->assertEquals($listName, $reloaded->getDatalist()->get('list_name')); + } + + public function testDatalistImportRestoresDatalistLink(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $listName = self::PREFIX . 'import_list'; + $this->makeDatalist($listName, $db)->store(); + $property = $this->importPropertyWithDatalist('env_import', 'datalist-strict', 'Env Import', $listName, $db); + + $exported = $property->export(); + $originalUuid = $exported->uuid; + + // Wipe the property from DB entirely, then re-import from the snapshot. + // This exercises the create() path inside import(), which does set $property->datalist. + $dba = $db->getDbAdapter(); + $uuidBytes = $property->get('uuid'); + $quotedUuid = DbUtil::quoteBinaryCompat($uuidBytes, $dba); + $dba->delete('director_property_datalist', $dba->quoteInto('property_uuid = ?', $quotedUuid)); + $dba->delete('director_property', $dba->quoteInto('uuid = ?', $quotedUuid)); + + $imported = DirectorProperty::import($exported, $db); + $imported->store(); + + $restored = DirectorProperty::loadWithUniqueId(Uuid::fromString($originalUuid), $db); + $this->assertNotNull($restored->getDatalist(), 'import() must restore the datalist link'); + $this->assertEquals($listName, $restored->getDatalist()->get('list_name')); + } + + /** + * Restoring a dynamic-dictionary property whose CHILD references a datalist that does + * not exist yet in the target database must not fail with "SQLSTATE[23000]: ..." error + * (a brand-new DirectorDatalist created during import() must be persisted before + * onStore() reads its uuid). + */ + public function testDatalistChildOfDynamicDictionaryIsPersistedWhenListDoesNotExistYet(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $parentKeyName = self::PREFIX . 'dict_with_new_list_child'; + $listName = self::PREFIX . 'never_seen_list_child'; + $this->createdKeyNames[] = $parentKeyName; + $this->createdListNames[] = $listName; + + $this->assertFalse(DirectorDatalist::exists($listName, $db), 'Precondition: datalist must not exist yet'); + + $parentUuid = Uuid::uuid4()->toString(); + $plain = (object) [ + 'uuid' => $parentUuid, + 'key_name' => $parentKeyName, + 'value_type' => 'dynamic-dictionary', + 'label' => 'Dict With New List Child', + 'parent_uuid' => null, + 'category' => null, + 'description' => null, + 'items' => [ + 'severity' => $this->datalistItemPlain('severity', $parentUuid, $listName), + ], + ]; + + $imported = DirectorProperty::import($plain, $db); + $imported->store(); + foreach ($imported->fetchItemsFromDb() as $child) { + $child->store(); + } + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromString($parentUuid), $db); + $items = $reloaded->fetchItemsFromDb(); + $this->assertCount(1, $items); + + $childDatalist = $items[0]->getDatalist(); + $this->assertNotNull( + $childDatalist, + 'Newly created datalist referenced by a dictionary child must be persisted and linked' + ); + $this->assertEquals($listName, $childDatalist->get('list_name')); + $this->assertNotNull($childDatalist->get('uuid'), 'Newly created datalist must have a persisted uuid'); + } + + public function testExportRoundTrip(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $parent = $this->makeProperty('disk_rt', 'fixed-dictionary', 'Disk RT', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + foreach (['warn', 'crit'] as $fieldName) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $fieldName, + 'parent_uuid' => $parentUuid, + 'value_type' => 'string', + ], $db)->store(); + } + + $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $db); + $exported = $reloaded->export(); + $originalUuid = $exported->uuid; + + // Wipe and re-import + $dba = $db->getDbAdapter(); + $quotedParentUuid = DbUtil::quoteBinaryCompat($parentUuid, $dba); + $dba->delete('director_property', $dba->quoteInto('parent_uuid = ?', $quotedParentUuid)); + $dba->delete('director_property', $dba->quoteInto('uuid = ?', $quotedParentUuid)); + + $imported = DirectorProperty::import($exported, $db); + $imported->store(); + foreach ($imported->fetchItemsFromDb() as $child) { + $child->store(); + } + + $restored = DirectorProperty::loadWithUniqueId(Uuid::fromString($originalUuid), $db); + $this->assertNotNull($restored); + $this->assertEquals('fixed-dictionary', $restored->get('value_type')); + $this->assertEquals(self::PREFIX . 'disk_rt', $restored->get('key_name')); + + $childKeys = array_map(fn($c) => $c->get('key_name'), $restored->fetchItemsFromDb()); + sort($childKeys); + $this->assertEquals(['crit', 'warn'], $childKeys); + } + + public function testDeletingAParentCascadesToItsChildren(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $parent = $this->makeProperty('cascade_parent', 'fixed-dictionary', 'Cascade Parent', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + $childKeyName = self::PREFIX . 'cascade_child'; + $this->createdKeyNames[] = $childKeyName; + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $childKeyName, + 'parent_uuid' => $parentUuid, + 'value_type' => 'string', + ], $db); + $child->store(); + $childUuid = $child->get('uuid'); + + $dba->delete( + 'director_property', + $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($parentUuid, $dba)) + ); + + $this->assertNull(DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $db)); + $this->assertNull(DirectorProperty::loadWithUniqueId(Uuid::fromBytes($childUuid), $db)); + } + + public function testImportIsIdempotent(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $property = $this->makeProperty('env_idem', 'string', 'Env Idempotent', $db); + $property->store(); + + $exported = $property->export(); + + // First import + $first = DirectorProperty::import($exported, $db); + if ($first->hasBeenModified()) { + $first->store(); + } + + // Second import + $second = DirectorProperty::import($exported, $db); + if ($second->hasBeenModified()) { + $second->store(); + } + + $uuidBytes = $property->get('uuid'); + $dba = $db->getDbAdapter(); + $count = $dba->fetchOne( + $dba->select() + ->from('director_property', ['cnt' => 'COUNT(*)']) + ->where('uuid = ?', DbUtil::quoteBinaryCompat($uuidBytes, $dba)) + ); + + $this->assertEquals(1, (int) $count, 'import() must not create duplicate rows'); + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + foreach ($this->createdKeyNames as $keyName) { + $rows = $dba->fetchAll( + $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', $keyName) + ); + foreach ($rows as $row) { + $uuid = DbUtil::binaryResult($row->uuid); + $descendants = $this->collectDescendantUuids($uuid, $dba); + foreach (array_merge([$uuid], $descendants) as $descendantUuid) { + $dba->delete( + 'director_property_datalist', + $dba->quoteInto('property_uuid = ?', DbUtil::quoteBinaryCompat($descendantUuid, $dba)) + ); + } + foreach ($descendants as $descendantUuid) { + $dba->delete( + 'director_property', + $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($descendantUuid, $dba)) + ); + } + } + $dba->delete('director_property', $dba->quoteInto('key_name = ?', $keyName)); + } + + foreach ($this->createdListNames as $listName) { + if (DirectorDatalist::exists($listName, $db)) { + DirectorDatalist::load($listName, $db)->delete(); + } + } + } + + parent::tearDown(); + } + + /** + * Recursively collect the raw binary UUIDs of all descendants (children, grandchildren, ...) + * of the property with the given raw binary UUID, not including $uuid itself. + */ + private function collectDescendantUuids(string $uuid, $dba): array + { + $descendants = []; + $parents = [$uuid]; + + while (! empty($parents)) { + $children = $dba->fetchCol( + $dba->select()->from('director_property', ['uuid']) + ->where('parent_uuid IN (?)', DbUtil::quoteBinaryCompat($parents, $dba)) + ); + $children = array_map([DbUtil::class, 'binaryResult'], $children); + + $descendants = array_merge($descendants, $children); + $parents = $children; + } + + return $descendants; + } + + private function makeProperty(string $suffix, string $valueType, string $label, Db $db): DirectorProperty + { + $keyName = self::PREFIX . $suffix; + $this->createdKeyNames[] = $keyName; + + return DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $keyName, + 'value_type' => $valueType, + 'label' => $label, + ], $db); + } + + private function makeDatalist(string $listName, Db $db): DirectorDatalist + { + $this->createdListNames[] = $listName; + + return DirectorDatalist::create(['list_name' => $listName, 'owner' => 'test'], $db); + } + + /** + * Build the plain export shape of a datalist-strict property nested under $parentUuid, + * referencing a datalist by name (as DirectorProperty::export() would produce it). + */ + private function datalistItemPlain(string $keyName, string $parentUuid, string $listName): object + { + return (object) [ + 'uuid' => Uuid::uuid4()->toString(), + 'key_name' => $keyName, + 'value_type' => 'datalist-strict', + 'label' => null, + 'parent_uuid' => $parentUuid, + 'category' => null, + 'description' => null, + 'datalist' => $listName, + 'items' => [], + ]; + } + + /** + * Use DirectorProperty::import() to create a datalist-backed property and store it. + * import() sets the private $datalist field, causing onStore() to insert the link row. + */ + private function importPropertyWithDatalist( + string $suffix, + string $valueType, + string $label, + string $listName, + Db $db + ): DirectorProperty { + $keyName = self::PREFIX . $suffix; + $this->createdKeyNames[] = $keyName; + $plain = (object) [ + 'uuid' => Uuid::uuid4()->toString(), + 'key_name' => $keyName, + 'value_type' => $valueType, + 'label' => $label, + 'parent_uuid' => null, + 'category' => null, + 'description' => null, + 'datalist' => $listName, + 'items' => [], + ]; + $property = DirectorProperty::import($plain, $db); + $property->store(); + + return $property; + } +} diff --git a/test/php/library/Director/Objects/IcingaHostTest.php b/test/php/library/Director/Objects/IcingaHostTest.php index f8ec222db..dfe34cfbf 100644 --- a/test/php/library/Director/Objects/IcingaHostTest.php +++ b/test/php/library/Director/Objects/IcingaHostTest.php @@ -1,17 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + namespace Tests\Icinga\Module\Director\Objects; use Icinga\Exception\NotFoundError; use Icinga\Module\Director\Data\PropertiesFilter\ArrayCustomVariablesFilter; use Icinga\Module\Director\Data\PropertiesFilter\CustomVariablesFilter; +use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\IcingaConfig\IcingaConfig; use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\DirectorProperty; use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaHostGroup; use Icinga\Module\Director\Objects\IcingaZone; +use Icinga\Module\Director\Repository\IcingaTemplateRepository; use Icinga\Module\Director\Test\BaseTestCase; -use Icinga\Exception\IcingaException; +use Ramsey\Uuid\Uuid; class IcingaHostTest extends BaseTestCase { @@ -731,6 +737,60 @@ protected function getDefaultHostProperties($prefix = '') "{$prefix}templates" => "templates" ); } + + public function testDynamicDictionaryVarUsesOverrideOperator(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + // Template needs at least one icinga_host_var row to satisfy the JOIN in + // CustomVariables::renderSingleVar() that detects dynamic-dictionary properties. + $template = IcingaHost::create([ + 'object_name' => '___TEST___linux-server', + 'object_type' => 'template', + 'vars' => ['env' => 'production'], + ], $db); + $template->store(); + + $property = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '___TEST___disk_checks_dyn', + 'value_type' => 'dynamic-dictionary', + 'label' => 'Disk Checks', + ], $db); + $property->store(); + + $dba = $db->getDbAdapter(); + $db->insert('icinga_host_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($property->get('uuid'), $dba), + 'host_uuid' => DbUtil::quoteBinaryCompat($template->get('uuid'), $dba), + ]); + + $child = IcingaHost::create([ + 'object_name' => '___TEST___db-server-01', + 'object_type' => 'object', + 'address' => '10.0.1.42', + 'vars' => [ + '___TEST___disk_checks_dyn' => (object) [ + 'root' => (object) ['mount_point' => '/', 'warn' => '20%', 'crit' => '10%'], + 'data' => (object) ['mount_point' => '/data', 'warn' => '15%', 'crit' => '5%'], + ], + ], + ], $db); + $child->imports = '___TEST___linux-server'; + $child->store(); + + $loaded = IcingaHost::load('___TEST___db-server-01', $db); + + $this->assertEquals( + $this->loadRendered('host_dynamic_dict'), + (string) $loaded + ); + } + protected function loadRendered($name) { return file_get_contents(__DIR__ . '/rendered/' . $name . '.out'); @@ -740,7 +800,15 @@ public function tearDown(): void { if ($this->hasDb()) { $db = $this->getDb(); - $kill = array($this->testHostName, '___TEST___parent', '___TEST___a', '___TEST___b'); + $dba = $db->getDbAdapter(); + $kill = array( + $this->testHostName, + '___TEST___parent', + '___TEST___a', + '___TEST___b', + '___TEST___db-server-01', + '___TEST___linux-server', + ); foreach ($kill as $name) { if (IcingaHost::exists($name, $db)) { IcingaHost::load($name, $db)->delete(); @@ -755,8 +823,19 @@ public function tearDown(): void } $this->deleteDatafields(); + + $rows = $dba->fetchAll( + $dba->select() + ->from('director_property', ['uuid']) + ->where('key_name = ?', '___TEST___disk_checks_dyn') + ); + foreach ($rows as $row) { + $dba->delete('director_property', $dba->quoteInto('parent_uuid = ?', $row->uuid)); + } + $dba->delete('director_property', $dba->quoteInto('key_name = ?', '___TEST___disk_checks_dyn')); } + IcingaTemplateRepository::clear(); parent::tearDown(); } diff --git a/test/php/library/Director/Objects/IcingaServiceApplyForTest.php b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php new file mode 100644 index 000000000..98ad7ea09 --- /dev/null +++ b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php @@ -0,0 +1,423 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Objects; + +use Icinga\Module\Director\DataType\DataTypeArray; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\DirectorProperty; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Test\BaseTestCase; +use Ramsey\Uuid\Uuid; + +/** + * Integration tests for IcingaService apply-for header rendering driven by DirectorProperty. + * + * Scenario: a host template for disk monitoring (dynamic-dictionary) and HTTP monitoring + * (dynamic-array). Apply-for services generate one service instance per entry. + */ +class IcingaServiceApplyForTest extends BaseTestCase +{ + private const PREFIX = '___TEST___'; + + protected $testHostName = self::PREFIX . 'host_apply_for'; + + /** @var string[] service object_names created during tests */ + private array $createdServices = []; + + /** @var string[] property key_names created during tests */ + private array $createdPropertyKeys = []; + + public function testApplyForDynamicArrayRendersForValue(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->hostTemplate(); + $host->store($db); + + $this->makeAndLinkProperty('http_vhosts', 'dynamic-array', $host, $db); + $applyFor = 'host.vars.' . self::PREFIX . 'http_vhosts'; + + $service = $this->applyService('http-check', $applyFor); + $service->setConnection($db); + + $rendered = (string) $service; + + $this->assertStringContainsString( + 'for (value in ' . $applyFor . ')', + $rendered + ); + + $this->assertStringNotContainsString('key => value', $rendered); + } + + public function testApplyForDynamicDictionaryRendersForKeyValue(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->hostTemplate(); + $host->store($db); + + $this->makeAndLinkProperty('disk_checks', 'dynamic-dictionary', $host, $db); + $applyFor = 'host.vars.' . self::PREFIX . 'disk_checks'; + + $service = $this->applyService('disk-check', $applyFor); + $service->setConnection($db); + + $rendered = (string) $service; + + $this->assertStringContainsString( + 'for (key => value in ' . $applyFor . ')', + $rendered + ); + } + + public function testApplyForWithNoPropertyFallsBackToValue(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $service = $this->applyService('ping-check', 'host.vars.unknown_var'); + $service->setConnection($db); + + $rendered = (string) $service; + + $this->assertStringContainsString( + 'for (value in host.vars.unknown_var)', + $rendered, + 'Apply-for with no matching director_property must fall back to (value in ...)' + ); + } + + public function testApplyForPrefersLegacyDatafieldOverUnrelatedSameNamedProperty(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // A Data field (deprecated), attached to the host template used by this test, + // predating Custom Variables. Its "Apply For" rule must keep rendering as a + // plain array iteration after the merge. + $host = $this->hostTemplate(); + $host->store($db); + + $keyName = self::PREFIX . 'shared_name'; + $datafield = DirectorDatafield::create([ + 'varname' => $keyName, + 'caption' => 'Shared name', + 'datatype' => DataTypeArray::class, + ], $db); + $datafield->store(); + + $dba->insert('icinga_host_field', [ + 'host_id' => $host->get('id'), + 'datafield_id' => $datafield->get('id'), + 'is_required' => 'n', + ]); + + // An unrelated Custom Variable, attached to a completely different host + // template, which merely happens to share the same key_name. + $otherHost = IcingaHost::create([ + 'object_name' => self::PREFIX . 'host_apply_for_other', + 'object_type' => 'template', + ]); + $otherHost->store($db); + $this->makeAndLinkProperty('shared_name', 'dynamic-dictionary', $otherHost, $db); + + $applyFor = 'host.vars.' . $keyName; + $service = $this->applyService('shared-name-check', $applyFor); + $service->setConnection($db); + + $rendered = (string) $service; + + $this->assertStringContainsString( + 'for (value in ' . $applyFor . ')', + $rendered, + 'A Data field must keep winning over an unrelated same-named Custom Variable' + ); + $this->assertStringNotContainsString('key => value', $rendered); + $this->assertStringNotContainsString('vars.overriddenVar', $rendered); + + $otherHost->delete(); + $datafield->delete(); + } + + public function testValueFieldMacroAllowedInVars(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->hostTemplate(); + $host->store($db); + + // Create disk_checks (dynamic-dictionary) with child mount_point + $parent = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => self::PREFIX . 'disk_checks_macro', + 'value_type' => 'dynamic-dictionary', + 'label' => 'Disk Checks', + ], $db); + $parent->store(); + $this->createdPropertyKeys[] = self::PREFIX . 'disk_checks_macro'; + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'mount_point', + 'parent_uuid' => $parent->get('uuid'), + 'value_type' => 'string', + ], $db); + $child->store(); + + $dba = $db->getDbAdapter(); + $db->insert('icinga_host_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($parent->get('uuid'), $dba), + 'host_uuid' => DbUtil::quoteBinaryCompat(DbUtil::binaryResult($host->get('uuid')), $dba), + ]); + + $service = $this->applyService( + 'disk-macro-check', + 'host.vars.' . self::PREFIX . 'disk_checks_macro' + ); + $service->setConnection($db); + $service->{'vars.mount'} = '$value.mount_point$'; + + $rendered = (string) $service; + + $this->assertStringContainsString( + 'vars.mount = value.mount_point', + $rendered, + 'Whitelisted value.mount_point macro must render as unquoted expression' + ); + } + + public function testQuotedDictionaryKeyMacroAllowedInVars(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->hostTemplate(); + $host->store($db); + + $this->makeAndLinkProperty('oncall_contacts', 'dynamic-dictionary', $host, $db); + + $service = $this->applyService('oncall-check', 'host.vars.' . self::PREFIX . 'oncall_contacts'); + $service->setConnection($db); + $service->{'vars.oncall'} = '$value["on call"]$'; + + $rendered = (string) $service; + + $this->assertStringContainsString( + 'vars.oncall = value["on call"]', + $rendered, + 'A quoted dictionary key with a space must render as an unquoted expression' + ); + } + + public function testUnrelatedMacroStrippedFromVars(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->hostTemplate(); + $host->store($db); + + $this->makeAndLinkProperty('disk_checks_strip', 'dynamic-dictionary', $host, $db); + + $service = $this->applyService('disk-strip-check', 'host.vars.' . self::PREFIX . 'disk_checks_strip'); + $service->setConnection($db); + $service->{'vars.secret'} = '$totally_unrelated_macro$'; + + $rendered = (string) $service; + + $this->assertStringContainsString( + 'vars.secret = "$totally_unrelated_macro$"', + $rendered, + 'A macro unrelated to the apply-for loop must render as a quoted string' + ); + } + + public function testDotAccessRejectedForArrayApplyFor(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->hostTemplate(); + $host->store($db); + + $this->makeAndLinkProperty('http_vhosts_strip', 'dynamic-array', $host, $db); + + $service = $this->applyService('http-strip-check', 'host.vars.' . self::PREFIX . 'http_vhosts_strip'); + $service->setConnection($db); + $service->{'vars.mount'} = '$value.mount_point$'; + + $rendered = (string) $service; + + $this->assertStringContainsString( + 'vars.mount = "$value.mount_point$"', + $rendered, + 'value.* dot access only makes sense for a dynamic-dictionary apply-for' + . ' and must stay quoted for a plain array' + ); + } + + public function testRenderedArrayApplyMatchesFixture(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->hostTemplate(); + $host->store($db); + + $this->makeAndLinkProperty('http_vhosts_fix', 'dynamic-array', $host, $db); + + $service = $this->applyService('http-check', 'host.vars.' . self::PREFIX . 'http_vhosts_fix'); + $service->setConnection($db); + $service->display_name = 'HTTP ' . chr(43) . ' value'; + $service->check_command = 'http'; + $service->{'vars.http_vhost'} = '$value$'; + $service->assign_filter = 'host.vars.' . self::PREFIX . 'http_vhosts_fix'; + + $rendered = (string) $service; + $fixture = file_get_contents(__DIR__ . '/rendered/service_apply_for_array.out'); + + $this->assertEquals($fixture, $rendered); + } + + public function testRenderedDictApplyMatchesFixture(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->hostTemplate(); + $host->store($db); + + $parent = $this->makeAndLinkProperty('disk_checks_fix', 'dynamic-dictionary', $host, $db); + + foreach (['mount_point', 'warn', 'crit'] as $fieldName) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $fieldName, + 'parent_uuid' => $parent->get('uuid'), + 'value_type' => 'string', + ], $db)->store(); + } + + $service = $this->applyService('disk-check', 'host.vars.' . self::PREFIX . 'disk_checks_fix'); + $service->setConnection($db); + $service->display_name = 'Disk ' . chr(43) . ' key'; + $service->check_command = 'disk'; + $service->{'vars.disk_mount'} = '$value.mount_point$'; + $service->{'vars.disk_warn'} = '$value.warn$'; + $service->{'vars.disk_crit'} = '$value.crit$'; + $service->assign_filter = 'host.vars.' . self::PREFIX . 'disk_checks_fix'; + + $rendered = (string) $service; + $fixture = file_get_contents(__DIR__ . '/rendered/service_apply_for_dict.out'); + + $this->assertEquals($fixture, $rendered); + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + foreach ($this->createdServices as $serviceName) { + if (IcingaService::exists(['object_name' => $serviceName], $db)) { + IcingaService::load(['object_name' => $serviceName], $db)->delete(); + } + } + + if (IcingaHost::exists($this->testHostName, $db)) { + IcingaHost::load($this->testHostName, $db)->delete(); + } + + foreach ($this->createdPropertyKeys as $keyName) { + $rows = $dba->fetchAll( + $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', $keyName) + ); + foreach ($rows as $row) { + $quotedUuid = DbUtil::quoteBinaryCompat(DbUtil::binaryResult($row->uuid), $dba); + $dba->delete('director_property', $dba->quoteInto('parent_uuid = ?', $quotedUuid)); + $dba->delete('icinga_host_property', $dba->quoteInto('property_uuid = ?', $quotedUuid)); + } + $dba->delete('director_property', $dba->quoteInto('key_name = ?', $keyName)); + } + } + + parent::tearDown(); + } + + private function hostTemplate(): IcingaHost + { + return IcingaHost::create([ + 'object_name' => $this->testHostName, + 'object_type' => 'template', + ]); + } + + private function applyService(string $serviceName, string $applyFor): IcingaService + { + $name = self::PREFIX . $serviceName; + $this->createdServices[] = $name; + return IcingaService::create([ + 'object_name' => $name, + 'object_type' => 'apply', + 'apply_for' => $applyFor, + ]); + } + + private function makeAndLinkProperty( + string $suffix, + string $valueType, + IcingaHost $host, + $db + ): DirectorProperty { + $keyName = self::PREFIX . $suffix; + $this->createdPropertyKeys[] = $keyName; + + $property = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $keyName, + 'value_type' => $valueType, + 'label' => ucfirst(str_replace('_', ' ', $suffix)), + ], $db); + $property->store(); + + $dba = $db->getDbAdapter(); + $db->insert('icinga_host_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($property->get('uuid'), $dba), + 'host_uuid' => DbUtil::quoteBinaryCompat(DbUtil::binaryResult($host->get('uuid')), $dba), + ]); + + return $property; + } +} diff --git a/test/php/library/Director/Objects/IcingaServiceTest.php b/test/php/library/Director/Objects/IcingaServiceTest.php index 3005349e3..84856206e 100644 --- a/test/php/library/Director/Objects/IcingaServiceTest.php +++ b/test/php/library/Director/Objects/IcingaServiceTest.php @@ -1,5 +1,8 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + namespace Tests\Icinga\Module\Director\Objects; use Icinga\Module\Director\IcingaConfig\IcingaConfig; @@ -218,7 +221,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 @@ -231,6 +234,54 @@ public function testApplyForRendersInVariousModes() ); } + public function testApplyForConfigMacroStaysBackwardCompatibleWithValue() + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + // Before the plain-array apply-for loop variable was renamed from "config" to + // "value", existing installations may have stored custom variable strings that + // reference the old macro name (e.g. "/dev/$config$"). Those strings live untouched + // in the database across an upgrade, so the renderer must still resolve "$config$", + // to the same loop variable now named "value", instead of silently leaving it + // as literal, un-substituted text in the compiled Icinga 2 configuration. + $service = $this->service()->setConnection($db); + $service->object_type = 'apply'; + $service->apply_for = 'host.vars.disks'; + $service->assign_filter = 'host.vars.env="test"'; + $service->{'vars.legacy_macro'} = '/dev/$config$'; + + $this->assertStringContainsString( + 'vars.legacy_macro = "/dev/" + value', + (string) $service + ); + } + + public function testApplyForNameMacroSupportsLegacyConfigAlias() + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + $service = $this->service()->setConnection($db); + $service->object_type = 'apply'; + $service->apply_for = 'host.vars.disks'; + $service->assign_filter = 'host.vars.env="test"'; + // legacy $config$ alias in object names, should resolve to "value" just like + // it already does for values stored in custom variables + $service->object_name = 'Disk check $config$'; + + $this->assertStringContainsString( + 'name = "Disk check " + value', + (string) $service + ); + } + protected function host() { return IcingaHost::create(array( diff --git a/test/php/library/Director/Objects/Lib/TestableMigrateCommand.php b/test/php/library/Director/Objects/Lib/TestableMigrateCommand.php new file mode 100644 index 000000000..63f39083c --- /dev/null +++ b/test/php/library/Director/Objects/Lib/TestableMigrateCommand.php @@ -0,0 +1,37 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Objects\Lib; + +use Icinga\Cli\Params; +use Icinga\Cli\Screen; +use Icinga\Module\Director\Clicommands\MigrateCommand; +use Icinga\Module\Director\Db; + +/** + * Test adapter that bypasses CLI bootstrap for MigrateCommand. + * + * Injects DB and params directly into the protected properties that + * the CLI base constructor normally sets from the running application. + */ +class TestableMigrateCommand extends MigrateCommand +{ + public function __construct(Db $db, array $argv = []) + { + $this->db = $db; + $this->params = new Params(array_merge(['program'], $argv)); + $this->isVerbose = in_array('--verbose', $argv); + $this->isDebugging = false; + $this->screen = Screen::instance(STDOUT); + } + + public function runDatafields(): string + { + ob_start(); + $this->datafieldsAction(); + + return (string) ob_get_clean(); + } +} diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php new file mode 100644 index 000000000..d10f44884 --- /dev/null +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -0,0 +1,825 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\DirectorDatafieldCategory; +use Icinga\Module\Director\Objects\DirectorDatalist; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaHostField; +use Icinga\Module\Director\Test\BaseTestCase; +use Ramsey\Uuid\Uuid; +use Tests\Icinga\Module\Director\Objects\Lib\TestableMigrateCommand; + +class MigrateCommandTest extends BaseTestCase +{ + private const PREFIX = '___TEST___'; + + // Migratable datafield varnames + private const VAR_ENV = self::PREFIX . 'env'; + + private const VAR_HTTP_VHOSTS = self::PREFIX . 'http_vhosts'; + + private const VAR_CHECK_INTERVAL = self::PREFIX . 'check_interval'; + + private const VAR_ENV_CHOICES = self::PREFIX . 'env_choices'; + + private const VAR_ENV_SUGGEST = self::PREFIX . 'env_suggest'; + + private const VAR_ENV_CHOICES_DEFAULT_BEHAVIOR = self::PREFIX . 'env_choices_default_behavior'; + + // Non-migratable datafield varnames + private const VAR_SQL_QUERY = self::PREFIX . 'sql_query_field'; + + private const VAR_CATEGORIZED = self::PREFIX . 'categorized_field'; + + // Migratable as 'sensitive' (legacy hidden-visibility string) + private const VAR_HIDDEN = self::PREFIX . 'snmp_community'; + + private const VAR_DUP = self::PREFIX . 'notification_email'; + + private const VAR_TIME_FIELD = self::PREFIX . 'time_field'; + + private const LIST_NAME = self::PREFIX . 'migrate_list'; + + private const CAT_NAME = self::PREFIX . 'migrate_category'; + + private const HOST_NAME = self::PREFIX . 'binding_host'; + + private const MIGRATABLE = [ + self::VAR_ENV, + self::VAR_HTTP_VHOSTS, + self::VAR_CHECK_INTERVAL, + self::VAR_ENV_CHOICES, + self::VAR_ENV_SUGGEST, + self::VAR_HIDDEN, + self::VAR_ENV_CHOICES_DEFAULT_BEHAVIOR, + ]; + + private const ALL_TEST_VARS = [ + self::VAR_ENV, + self::VAR_HTTP_VHOSTS, + self::VAR_CHECK_INTERVAL, + self::VAR_ENV_CHOICES, + self::VAR_ENV_SUGGEST, + self::VAR_ENV_CHOICES_DEFAULT_BEHAVIOR, + self::VAR_SQL_QUERY, + self::VAR_CATEGORIZED, + self::VAR_HIDDEN, + self::VAR_DUP, + self::VAR_TIME_FIELD, + self::PREFIX . 'tls_cert_path', + self::PREFIX . 'tls_key_path', + ]; + + public function testDryRunPrintsWhatWouldMigrateWithoutWriting(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db, ['--dry-run']); + $output = $cmd->runDatafields(); + + foreach (self::MIGRATABLE as $varname) { + $this->assertStringContainsString( + $varname, + $output, + "Dry-run output must list '$varname' as migratable" + ); + } + + $dba = $db->getDbAdapter(); + foreach (self::MIGRATABLE as $varname) { + $count = $dba->fetchOne( + $dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', $varname) + ); + + $this->assertEquals(0, (int) $count, "Dry-run must not create director_property for '$varname'"); + } + } + + public function testLiveMigrationCreatesDirectorPropertyRows(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + foreach (self::MIGRATABLE as $varname) { + $count = $dba->fetchOne( + $dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', $varname) + ); + $this->assertEquals(1, (int) $count, "Migration must create director_property for '$varname'"); + } + } + + public function testArrayDatafieldMigratesAsDynamicArray(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $row = $dba->fetchRow( + $dba->select()->from('director_property', ['value_type'])->where('key_name = ?', self::VAR_HTTP_VHOSTS) + ); + + $this->assertNotFalse($row, 'http_vhosts property must be created'); + $this->assertEquals('dynamic-array', $row->value_type); + } + + public function testDatalistStrictMigratesCorrectly(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $row = $dba->fetchRow( + $dba->select()->from('director_property', ['value_type'])->where('key_name = ?', self::VAR_ENV_CHOICES) + ); + + $this->assertNotFalse($row, 'env_choices property must be created'); + $this->assertEquals('datalist-strict', $row->value_type); + } + + public function testDatalistNonStrictMigratesCorrectly(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $row = $dba->fetchRow( + $dba->select()->from('director_property', ['value_type'])->where('key_name = ?', self::VAR_ENV_SUGGEST) + ); + + $this->assertNotFalse($row, 'env_suggest property must be created'); + $this->assertEquals('datalist-non-strict', $row->value_type); + } + + public function testDatalistWithoutExplicitBehaviorDefaultsToStrict(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $row = $dba->fetchRow( + $dba->select()->from('director_property', ['value_type']) + ->where('key_name = ?', self::VAR_ENV_CHOICES_DEFAULT_BEHAVIOR) + ); + + $this->assertNotFalse($row, 'env_choices_default_behavior property must be created'); + $this->assertEquals( + 'datalist-strict', + $row->value_type, + 'a datalist datafield with no explicit "behavior" setting must migrate as strict, ' + . 'matching DataTypeDatalist\'s own default' + ); + } + + public function testDatalistStrictMigrationLinksDatalist(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $property = $dba->fetchRow( + $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', self::VAR_ENV_CHOICES) + ); + $this->assertNotFalse($property, 'env_choices property must be created'); + + $linkedListName = $dba->fetchOne( + $dba->select()->from(['dd' => 'director_datalist'], ['list_name']) + ->join(['dpdl' => 'director_property_datalist'], 'dpdl.list_uuid = dd.uuid', []) + ->where( + 'dpdl.property_uuid = ?', + DbUtil::quoteBinaryCompat(DbUtil::binaryResult($property->uuid), $dba) + ) + ); + + $this->assertEquals( + self::LIST_NAME, + $linkedListName, + 'migrating a legacy datalist-strict datafield must link the new property to its datalist' + ); + } + + public function testDeleteOptionRemovesMigratedDatafields(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db, ['--delete']); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + foreach (self::MIGRATABLE as $varname) { + $dfCount = $dba->fetchOne( + $dba->select()->from('director_datafield', ['cnt' => 'COUNT(*)'])->where('varname = ?', $varname) + ); + $this->assertEquals(0, (int) $dfCount, "--delete must remove director_datafield for '$varname'"); + + $propCount = $dba->fetchOne( + $dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', $varname) + ); + $this->assertEquals(1, (int) $propCount, "director_property must survive --delete for '$varname'"); + } + } + + public function testDeleteIsSkippedOnDryRun(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db, ['--dry-run', '--delete']); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + foreach (self::MIGRATABLE as $varname) { + $count = $dba->fetchOne( + $dba->select()->from('director_datafield', ['cnt' => 'COUNT(*)'])->where('varname = ?', $varname) + ); + + $this->assertEquals( + 1, + (int) $count, + "--dry-run --delete must not remove director_datafield for '$varname'" + ); + } + } + + public function testCategorizedDatafieldIsSkipped(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $count = $dba->fetchOne( + $dba->select()->from( + 'director_property', + ['cnt' => 'COUNT(*)'] + )->where('key_name = ?', self::VAR_CATEGORIZED) + ); + $this->assertEquals(0, (int) $count, 'Categorized datafield must not be migrated'); + } + + public function testHiddenStringFieldMigratesAsSensitive(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $row = $dba->fetchRow( + $dba->select()->from('director_property', ['value_type'])->where('key_name = ?', self::VAR_HIDDEN) + ); + + $this->assertNotFalse($row, 'snmp_community property must be created'); + $this->assertEquals( + 'sensitive', + $row->value_type, + 'a legacy string datafield with visibility=hidden must migrate as the sensitive value type' + ); + } + + public function testUnsupportedTypeIsSkipped(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $count = $dba->fetchOne( + $dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', self::VAR_SQL_QUERY) + ); + $this->assertEquals(0, (int) $count, 'SqlQuery datafield must not be migrated (unsupported type)'); + } + + public function testUnsupportedTimeTypeIsSkippedEvenWithoutVerbose(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $count = $dba->fetchOne( + $dba->select() + ->from('director_property', ['cnt' => 'COUNT(*)']) + ->where('key_name = ?', self::VAR_TIME_FIELD) + ); + $this->assertEquals(0, (int) $count); + } + + public function testTotalMigratedCountExcludesUnsupportedTypes(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $output = $cmd->runDatafields(); + + $expectedMigrated = count(self::MIGRATABLE); + $this->assertStringContainsString( + "Total number of datafields migrated: $expectedMigrated\n", + $output, + 'the migrated count must not include datafields with an unsupported type that were skipped' + ); + } + + public function testMigrateDatafieldsRollsBackOnMidLoopFailure(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $sharedUuid = Uuid::uuid4()->getBytes(); + $customProperties = [ + self::PREFIX . 'tls_cert_path' => [ + 'datafield_id' => 90001, + 'uuid' => $sharedUuid, + 'key_name' => self::PREFIX . 'tls_cert_path', + 'label' => null, + 'description' => null, + 'category_id' => null, + 'value_type' => 'string', + ], + self::PREFIX . 'tls_key_path' => [ + 'datafield_id' => 90002, + 'uuid' => $sharedUuid, + 'key_name' => self::PREFIX . 'tls_key_path', + 'label' => null, + 'description' => null, + 'category_id' => null, + 'value_type' => 'string', + ], + ]; + + $cmd = new TestableMigrateCommand($db); + + try { + self::callMethod($cmd, 'migrateDatafields', [$customProperties, false]); + $this->fail('Expected an exception from the duplicate uuid on the second insert'); + } catch (\Throwable $e) { + // expected + } + + $this->assertFalse( + $dba->getConnection()->inTransaction(), + 'migrateDatafields() must roll back its transaction when interrupted by an exception' + ); + + $count = $dba->fetchOne( + $dba->select()->from('director_property', ['cnt' => 'COUNT(*)']) + ->where('key_name = ?', self::PREFIX . 'tls_cert_path') + ); + $this->assertEquals( + 0, + (int) $count, + 'the first insert must be rolled back along with the failing second one' + ); + } + + public function testDuplicateNamesAreSkipped(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $count = $dba->fetchOne( + $dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', self::VAR_DUP) + ); + $this->assertEquals(0, (int) $count, 'Duplicate-named datafield must not be migrated'); + } + + public function testExistingCustomPropertyBlocksMigration(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + // Pre-create a director_property with key_name matching VAR_ENV + $db->insert('director_property', [ + 'uuid' => DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $db->getDbAdapter()), + 'key_name' => self::VAR_ENV, + 'value_type' => 'string', + ]); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $count = $dba->fetchOne( + $dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', self::VAR_ENV) + ); + $this->assertEquals(1, (int) $count, 'Pre-existing custom property must not be duplicated by migration'); + } + + public function testObjectTemplateBindingPreservesIsRequired(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + $this->createHostFieldBinding($db); + + $cmd = new TestableMigrateCommand($db); + $cmd->runDatafields(); + + $dba = $db->getDbAdapter(); + $row = $dba->fetchRow( + $dba->select()->from(['ihp' => 'icinga_host_property'], ['required']) + ->join(['dp' => 'director_property'], 'dp.uuid = ihp.property_uuid', []) + ->where('dp.key_name = ?', self::VAR_ENV) + ); + + $this->assertNotFalse($row, 'icinga_host_property row must be created for the bound host'); + $this->assertEquals('y', $row->required, 'is_required must be carried over into the new required column'); + } + + public function testObjectTemplateBindingWarnsAboutUnmigratedVarFilter(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + $this->createHostFieldBinding($db); + + $cmd = new TestableMigrateCommand($db); + $output = $cmd->runDatafields(); + + $this->assertStringContainsString( + "Datafield '" . self::VAR_ENV . "' has a var_filter set for its icinga_host binding", + $output + ); + + $dba = $db->getDbAdapter(); + $count = $dba->fetchOne( + $dba->select()->from(['ihp' => 'icinga_host_property'], ['cnt' => 'COUNT(*)']) + ->join(['dp' => 'director_property'], 'dp.uuid = ihp.property_uuid', []) + ->where('dp.key_name = ?', self::VAR_ENV) + ); + $this->assertEquals(1, (int) $count, 'the binding must still be created even though its filter is dropped'); + } + + public function testObjectTemplateBindingWithoutFilterDoesNotWarn(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + $this->createHostFieldBinding($db); + + $cmd = new TestableMigrateCommand($db); + $output = $cmd->runDatafields(); + + $this->assertStringNotContainsString( + "Datafield '" . self::VAR_CHECK_INTERVAL . "' has a var_filter", + $output + ); + + $dba = $db->getDbAdapter(); + $row = $dba->fetchRow( + $dba->select()->from(['ihp' => 'icinga_host_property'], ['required']) + ->join(['dp' => 'director_property'], 'dp.uuid = ihp.property_uuid', []) + ->where('dp.key_name = ?', self::VAR_CHECK_INTERVAL) + ); + + $this->assertNotFalse($row, 'icinga_host_property row must be created for the bound host'); + $this->assertEquals('n', $row->required); + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + if ($dba->getConnection()->inTransaction()) { + $dba->getConnection()->rollBack(); + } + + // Delete the host before the properties/datafields below — icinga_host cascades + // to icinga_host_field and icinga_host_property. + $dba->delete('icinga_host', ['object_name = ?' => self::HOST_NAME]); + + $this->deleteTestProperties($db); + $this->deleteTestDatafields($db); + $this->deleteTestCategory($db); + $this->deleteTestDatalist($db); + } + + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Fixture helpers + // ------------------------------------------------------------------------- + + private function createAllFixtures(Db $db): void + { + if (! DirectorDatalist::exists(self::LIST_NAME, $db)) { + DirectorDatalist::create(['list_name' => self::LIST_NAME, 'owner' => 'test'], $db)->store(); + } + $datalist = DirectorDatalist::load(self::LIST_NAME, $db); + $datalistId = $datalist->get('id'); + + if (! DirectorDatafieldCategory::exists(self::CAT_NAME, $db)) { + DirectorDatafieldCategory::create(['category_name' => self::CAT_NAME], $db)->store(); + } + + $category = DirectorDatafieldCategory::load(self::CAT_NAME, $db); + $categoryId = $category->get('id'); + + $this->deleteTestDatafields($db); + + // 1. env — string + DirectorDatafield::create([ + 'varname' => self::VAR_ENV, + 'caption' => 'Environment', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', + ], $db)->store(); + + // 2. http_vhosts — array + DirectorDatafield::create([ + 'varname' => self::VAR_HTTP_VHOSTS, + 'caption' => 'HTTP Vhosts', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeArray', + ], $db)->store(); + + // 3. check_interval — number + DirectorDatafield::create([ + 'varname' => self::VAR_CHECK_INTERVAL, + 'caption' => 'Check Interval', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeNumber', + ], $db)->store(); + + // 4. env_choices — datalist-strict + $field = DirectorDatafield::create([ + 'varname' => self::VAR_ENV_CHOICES, + 'caption' => 'Environment Choices', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeDatalist', + ], $db); + $field->set('behavior', 'strict'); + $field->set('data_type', 'string'); + $field->set('datalist_id', $datalistId); + $field->store(); + + // 5. env_suggest — datalist-non-strict + $field = DirectorDatafield::create([ + 'varname' => self::VAR_ENV_SUGGEST, + 'caption' => 'Environment Suggest', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeDatalist', + ], $db); + $field->set('behavior', 'suggest'); + $field->set('data_type', 'string'); + $field->set('datalist_id', $datalistId); + $field->store(); + + // 5b. env_choices_default_behavior — datalist datafield with no explicit 'behavior' + // setting; must default to strict, matching DataTypeDatalist::getSetting('behavior', 'strict'). + $field = DirectorDatafield::create([ + 'varname' => self::VAR_ENV_CHOICES_DEFAULT_BEHAVIOR, + 'caption' => 'Environment Choices Default Behavior', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeDatalist', + ], $db); + $field->set('data_type', 'string'); + $field->set('datalist_id', $datalistId); + $field->store(); + + // 6. sql_query_field — unsupported type + DirectorDatafield::create([ + 'varname' => self::VAR_SQL_QUERY, + 'caption' => 'SQL Query', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeSqlQuery', + ], $db)->store(); + + // 7. categorized_field — has a category (skip) + $field = DirectorDatafield::create([ + 'varname' => self::VAR_CATEGORIZED, + 'caption' => 'Categorized Field', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', + 'category_id' => $categoryId, + ], $db); + $field->store(); + + // 8. snmp_community — string with visibility=hidden, migrates as 'sensitive' + $field = DirectorDatafield::create([ + 'varname' => self::VAR_HIDDEN, + 'caption' => 'SNMP Community String', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', + ], $db); + $field->set('visibility', 'hidden'); + $field->store(); + + // 9. notification_email × 2 — duplicate varname (skip both) + // DirectorDatafield has no uniqueness constraint on varname, so raw insert is safe. + $dba = $db->getDbAdapter(); + $dba->insert('director_datafield', [ + 'uuid' => DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $dba), + 'varname' => self::VAR_DUP, + 'caption' => 'Notification Email (added by the ops team)', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', + ]); + $dba->insert('director_datafield', [ + 'uuid' => DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $dba), + 'varname' => self::VAR_DUP, + 'caption' => 'Notification Email (added by the NOC)', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', + ]); + + // 10. time_field — unsupported type + DirectorDatafield::create([ + 'varname' => self::VAR_TIME_FIELD, + 'caption' => 'Time Field', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeTime', + ], $db)->store(); + } + + private function createHostFieldBinding(Db $db): IcingaHost + { + $host = IcingaHost::create([ + 'object_name' => self::HOST_NAME, + 'object_type' => 'template', + ], $db); + $host->store(); + + $dba = $db->getDbAdapter(); + $envFieldId = $dba->fetchOne( + $dba->select()->from('director_datafield', ['id'])->where('varname = ?', self::VAR_ENV) + ); + IcingaHostField::create([ + 'host_id' => $host->get('id'), + 'datafield_id' => $envFieldId, + 'is_required' => 'y', + 'var_filter' => 'host.vars.os=Linux', + ], $db)->store(); + + $checkIntervalFieldId = $dba->fetchOne( + $dba->select()->from('director_datafield', ['id'])->where('varname = ?', self::VAR_CHECK_INTERVAL) + ); + IcingaHostField::create([ + 'host_id' => $host->get('id'), + 'datafield_id' => $checkIntervalFieldId, + 'is_required' => 'n', + ], $db)->store(); + + return $host; + } + + private function deleteTestDatafields(Db $db): void + { + $dba = $db->getDbAdapter(); + foreach (self::ALL_TEST_VARS as $varname) { + $rows = $dba->fetchAll( + $dba->select()->from('director_datafield', ['id'])->where('varname = ?', $varname) + ); + foreach ($rows as $row) { + $dba->delete('director_datafield_setting', $dba->quoteInto('datafield_id = ?', $row->id)); + } + + $dba->delete('director_datafield', $dba->quoteInto('varname = ?', $varname)); + } + } + + private function deleteTestProperties(Db $db): void + { + $dba = $db->getDbAdapter(); + foreach (self::ALL_TEST_VARS as $varname) { + $rows = $dba->fetchAll( + $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', $varname) + ); + + foreach ($rows as $row) { + $dba->delete( + 'director_property', + $dba->quoteInto( + 'parent_uuid = ?', + DbUtil::quoteBinaryCompat(DbUtil::binaryResult($row->uuid), $dba) + ) + ); + } + + $dba->delete('director_property', $dba->quoteInto('key_name = ?', $varname)); + } + } + + private function deleteTestCategory(Db $db): void + { + if (DirectorDatafieldCategory::exists(self::CAT_NAME, $db)) { + $db->getDbAdapter()->delete( + 'director_datafield_category', + $db->getDbAdapter()->quoteInto('category_name = ?', self::CAT_NAME) + ); + } + } + + private function deleteTestDatalist(Db $db): void + { + if (DirectorDatalist::exists(self::LIST_NAME, $db)) { + DirectorDatalist::load(self::LIST_NAME, $db)->delete(); + } + } +} diff --git a/test/php/library/Director/Objects/rendered/host_dynamic_dict.out b/test/php/library/Director/Objects/rendered/host_dynamic_dict.out new file mode 100644 index 000000000..2e09182aa --- /dev/null +++ b/test/php/library/Director/Objects/rendered/host_dynamic_dict.out @@ -0,0 +1,18 @@ +object Host "___TEST___db-server-01" { + import "___TEST___linux-server" + + address = "10.0.1.42" + vars["___TEST___disk_checks_dyn"] += { + data = { + crit = "5%" + mount_point = "/data" + warn = "15%" + } + root = { + crit = "10%" + mount_point = "/" + warn = "20%" + } + } +} + 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" diff --git a/test/php/library/Director/Objects/rendered/service_apply_for_array.out b/test/php/library/Director/Objects/rendered/service_apply_for_array.out new file mode 100644 index 000000000..1d339aaf2 --- /dev/null +++ b/test/php/library/Director/Objects/rendered/service_apply_for_array.out @@ -0,0 +1,11 @@ +apply Service "___TEST___http-check" for (value in host.vars.___TEST___http_vhosts_fix) { + + vars.overriddenVar = "___TEST___http-check" + display_name = "HTTP + value" + check_command = "http" + assign where host.vars.___TEST___http_vhosts_fix == 1 + vars.http_vhost = value + + import DirectorOverrideTemplate +} + diff --git a/test/php/library/Director/Objects/rendered/service_apply_for_dict.out b/test/php/library/Director/Objects/rendered/service_apply_for_dict.out new file mode 100644 index 000000000..4c80982dd --- /dev/null +++ b/test/php/library/Director/Objects/rendered/service_apply_for_dict.out @@ -0,0 +1,13 @@ +apply Service "___TEST___disk-check" for (key => value in host.vars.___TEST___disk_checks_fix) { + + vars.overriddenVar = "___TEST___disk-check" + display_name = "Disk + key" + check_command = "disk" + assign where host.vars.___TEST___disk_checks_fix == 1 + vars.disk_crit = value.crit + vars.disk_mount = value.mount_point + vars.disk_warn = value.warn + + import DirectorOverrideTemplate +} + diff --git a/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php b/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php new file mode 100644 index 000000000..69572c47e --- /dev/null +++ b/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php @@ -0,0 +1,214 @@ +get('db', 'resource'), independently of + // BaseTestCase's own db handling. Point it at the very same resources.ini + // entry BaseTestCase uses, so both sides talk to the same test database. + if ($this->hasDb()) { + Config::module('director')->setSection('db', ['resource' => static::getDbResourceName()]); + } + } + + public function testGetObjectCustomPropertiesQueryRunsWithACategoryJoined(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + if (IcingaHost::exists(self::TEMPLATE_NAME, $db)) { + IcingaHost::load(self::TEMPLATE_NAME, $db)->delete(); + } + + $host = IcingaHost::create([ + 'object_name' => self::TEMPLATE_NAME, + 'object_type' => 'template', + ]); + $host->store($db); + + $category = DirectorDatafieldCategory::create([ + 'category_name' => self::CATEGORY_NAME, + ], $db); + $category->store(); + + $property = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => self::PROPERTY_KEY, + 'value_type' => 'string', + 'label' => 'SSH credentials', + 'category_id' => $category->get('id'), + ], $db); + $property->store(); + + $dba->insert('icinga_host_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($property->get('uuid'), $dba), + 'host_uuid' => DbUtil::quoteBinaryCompat($host->get('uuid'), $dba), + ]); + + $renderer = new CustomVarRenderer(); + $method = new ReflectionMethod($renderer, 'getObjectCustomProperties'); + $method->setAccessible(true); + + // This must not throw. Under PostgreSQL (and MySQL with ONLY_FULL_GROUP_BY), + // selecting cpc.category_name / iop.host_uuid without grouping or aggregating + // them raises a SQL error instead of returning a result. + $result = $method->invoke($renderer, $host); + + $this->assertArrayHasKey(self::PROPERTY_KEY, $result); + $this->assertEquals(self::CATEGORY_NAME, $result[self::PROPERTY_KEY]['category']); + } + + public function testSameNamedChildrenInDifferentDictionariesDoNotShareVisibility(): void + { + $renderer = new TestableCustomVarRenderer(); + + // Two unrelated dictionaries each have a "password" child. Only the database + // connection's password is meant to be masked - the monitoring API key's + // "password" child (a plain webhook token field) must render in the clear. + $renderer->seedDictionaryChild('database_connection', 'password', [ + 'label' => 'Database password', + 'visibility' => 'hidden', + ]); + $renderer->seedDictionaryChild('monitoring_api_key', 'password', [ + 'label' => 'Webhook token', + ]); + $renderer->seedDictionaryName('database_connection'); + $renderer->seedDictionaryName('monitoring_api_key'); + + $sensitiveRendered = $renderer->renderCustomVarValue('password', 's3cr3t-db-pass', 'database_connection'); + $plainRendered = $renderer->renderCustomVarValue('password', 'webhook-token-abc123', 'monitoring_api_key'); + + $this->assertEquals('***', $sensitiveRendered); + // A plain value with nothing special to render returns null by design, letting + // the caller fall back to the raw value (see the "?? $value" call sites in + // renderDictionaryVal()). The point of this assertion is that it must NOT be + // masked just because an unrelated dictionary has a sensitive child sharing the + // same key_name. + $this->assertNotEquals( + '***', + $plainRendered, + 'A non-sensitive dictionary child must not be masked due to a same-named ' + . 'sensitive child in a different dictionary' + ); + } + + public function testSameNamedNestedDictionariesUnderDifferentRootsDoNotShareVisibility(): void + { + $renderer = new TestableCustomVarRenderer(); + + // Two top level dictionaries each nest a "credentials" dictionary with a + // password child. Only server_a's is sensitive, server_b's must stay in the clear. + $renderer->seedDictionaryChild('credentials', 'password', [ + 'label' => 'Database password', + 'visibility' => 'hidden', + ], 'server_a'); + $renderer->seedDictionaryChild('credentials', 'password', [ + 'label' => 'API token', + ], 'server_b'); + + $sensitiveRendered = $renderer->renderCustomVarValue('password', 's3cr3t', 'credentials', 'server_a'); + $plainRendered = $renderer->renderCustomVarValue('password', 'plain-token', 'credentials', 'server_b'); + + $this->assertEquals('***', $sensitiveRendered); + $this->assertNotEquals( + '***', + $plainRendered, + 'A non-sensitive child must not be masked by a same-named sensitive child under an unrelated root' + ); + } + + public function testSameNamedArrayItemsUnderDifferentDictionariesDoNotShareMasking(): void + { + $renderer = new TestableCustomVarRenderer(); + + // Two dictionaries each nest a fixed-array named "targets". Only network_a's + // second item is sensitive, network_b's matching position must stay in the clear. + $renderer->seedDictionaryChild('network_a', 'targets', ['label' => 'Targets']); + $renderer->seedDictionaryChild('network_b', 'targets', ['label' => 'Targets']); + $renderer->seedSensitiveArrayItem('targets', '1', 'network_a'); + + $maskedValue = $renderer->renderCustomVarValue('targets', ['host1', 'secret-host2'], 'network_a'); + $plainValue = $renderer->renderCustomVarValue('targets', ['host1', 'host2'], 'network_b'); + + $this->assertEquals('***', $maskedValue[1]); + $this->assertNotEquals( + '***', + $plainValue[1], + 'An array item must not be masked by a same-named sensitive item in an unrelated dictionary' + ); + } + + public function testAppliedForArrayUsesItsValuesAsNameSuffix(): void + { + $renderer = new CustomVarRenderer(); + $method = new ReflectionMethod($renderer, 'isGeneratedApplyForServiceName'); + $method->setAccessible(true); + + $hostVar = CustomVariable::create('datacenters', ['fra', 'ams']); + + $this->assertTrue($method->invoke($renderer, $hostVar, 'vhost-', 'vhost-fra')); + $this->assertFalse($method->invoke($renderer, $hostVar, 'vhost-', 'vhost-lhr')); + } + + public function testAppliedForDictionaryUsesItsKeysNotValuesAsNameSuffix(): void + { + $renderer = new CustomVarRenderer(); + $method = new ReflectionMethod($renderer, 'isGeneratedApplyForServiceName'); + $method->setAccessible(true); + + // Each value is itself an array, which broke the old by-value lookup since + // it tried to concatenate an array into a string key. + $hostVar = CustomVariable::create('vhosts', [ + 'shop.example.com' => ['port' => 443, 'tls' => true], + 'blog.example.com' => ['port' => 80, 'tls' => false], + ]); + + $this->assertTrue($method->invoke($renderer, $hostVar, 'vhost-', 'vhost-shop.example.com')); + $this->assertFalse($method->invoke($renderer, $hostVar, 'vhost-', 'vhost-status.example.com')); + } + + protected function tearDown(): void + { + $db = $this->hasDb() ? $this->getDb() : null; + + if ($db && IcingaHost::exists(self::TEMPLATE_NAME, $db)) { + IcingaHost::load(self::TEMPLATE_NAME, $db)->delete(); + } + + if ($db && DirectorProperty::exists(self::PROPERTY_KEY, $db)) { + DirectorProperty::load(self::PROPERTY_KEY, $db)->delete(); + } + + if ($db && DirectorDatafieldCategory::exists(self::CATEGORY_NAME, $db)) { + DirectorDatafieldCategory::load(self::CATEGORY_NAME, $db)->delete(); + } + + parent::tearDown(); + } +} diff --git a/test/php/library/Director/ProvidedHook/Icingadb/TestableCustomVarRenderer.php b/test/php/library/Director/ProvidedHook/Icingadb/TestableCustomVarRenderer.php new file mode 100644 index 000000000..77b5da1b0 --- /dev/null +++ b/test/php/library/Director/ProvidedHook/Icingadb/TestableCustomVarRenderer.php @@ -0,0 +1,36 @@ +dictionaryChildConfig[$this->scopeKey($grandparentKey, $parentKey)][$childKey] = $config; + } + + public function seedSensitiveArrayItem(string $parentKey, string $childKey, ?string $grandparentKey = null): void + { + $this->sensitiveArrayItems[$this->scopeKey($grandparentKey, $parentKey)][$childKey] = true; + } + + public function seedDictionaryName(string $key): void + { + $this->dictionaryNames[] = $key; + } + + public function renderDictionaryValForTest(string $key, array $value) + { + return $this->renderDictionaryVal($key, $value); + } +} diff --git a/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php b/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php new file mode 100644 index 000000000..2d0cf2ca7 --- /dev/null +++ b/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php @@ -0,0 +1,374 @@ +skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->createTemplate($db); + + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $host, + [self::PREFIX . 'never_set' => null], + 'variables', + 'POST', + false + )); + + $reloaded = IcingaHost::load(self::TEMPLATE_NAME, $db); + $this->assertNull($reloaded->vars()->get(self::PREFIX . 'never_set')); + } + + public function testFailedValidationRollsBackFullReplace(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->createTemplate($db); + $this->attachProperties($host, $db); + + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $host, + [self::ENV_KEY => 'production'], + 'variables', + 'PUT', + false + )); + + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + $this->assertEquals('production', $host->vars()->get(self::ENV_KEY)->getValue()); + + try { + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $host, + [self::MYSQL_KEY => ['not', 'a', 'dictionary']], + 'variables', + 'PUT', + false + )); + $this->fail('Expected an InvalidArgumentException for a mismatched value shape'); + } catch (InvalidArgumentException $e) { + // expected, checked below via a fresh load + } + + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + $this->assertNotNull( + $host->vars()->get(self::ENV_KEY), + 'A failed PUT must not lose the variables that existed before it started' + ); + $this->assertEquals('production', $host->vars()->get(self::ENV_KEY)->getValue()); + } + + public function testReplaceAllRemovesUnmentionedVariablesOnPost(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->createTemplate($db); + $this->attachProperties($host, $db); + + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $host, + [self::ENV_KEY => 'production'], + 'variables', + 'PUT', + false + )); + + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $host, + [self::MYSQL_KEY => (object) ['host' => 'db-primary']], + 'index', + 'POST', + true + )); + + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + $this->assertNull( + $host->vars()->get(self::ENV_KEY), + 'A full vars dictionary replace must drop variables that were not mentioned' + ); + $this->assertNotNull($host->vars()->get(self::MYSQL_KEY)); + } + + public function testReplaceAllWithNoOverridesClearsEveryVariable(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->createTemplate($db); + $this->attachProperties($host, $db); + + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $host, + [self::ENV_KEY => 'production'], + 'variables', + 'PUT', + false + )); + + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + + // This is the base endpoint equivalent of a POST body of {"vars": {}}, + // an explicit but empty full vars dictionary must still clear everything. + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $host, + [], + 'index', + 'POST', + true + )); + + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + $this->assertNull( + $host->vars()->get(self::ENV_KEY), + 'An explicit empty vars dictionary must clear existing variables, not no op' + ); + } + + public function testApplyDoesNotCommitOrRollBackWhenAlreadyInsideATransaction(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->createTemplate($db); + $this->attachProperties($host, $db); + + $dbAdapter = $db->getDbAdapter(); + $dbAdapter->beginTransaction(); + + try { + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $host, + [self::ENV_KEY => 'production'], + 'variables', + 'PUT', + false + )); + } catch (Throwable $e) { + $dbAdapter->rollBack(); + $this->fail( + 'apply() must not manage its own transaction when the caller already opened one: ' + . get_class($e) . ': ' . $e->getMessage() + ); + } + + // The write must be visible within the still-open outer transaction (same + // connection, so this is a read of its own uncommitted write) - this proves + // apply() actually performed the write rather than silently no-op'ing. + $reloadedWithinTransaction = IcingaHost::load(self::TEMPLATE_NAME, $db); + $this->assertEquals( + 'production', + $reloadedWithinTransaction->vars()->get(self::ENV_KEY)->getValue(), + 'apply() must still perform its writes even when it does not own the transaction' + ); + + // Simulate a caller (e.g. IcingaObjectHandler) that persists other changes in the + // same outer transaction and fails afterward - the whole thing must roll back together. + $dbAdapter->rollBack(); + + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + $this->assertNull( + $host->vars()->get(self::ENV_KEY), + 'apply() must not commit its own writes when called inside an existing transaction, ' + . 'so a failure later in the same caller-owned transaction can still undo everything' + ); + } + + public function testBaseObjectPutKeepsPropertyAttachmentsIntact(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->createTemplate($db); + // Attaches both ENV_KEY and MYSQL_KEY directly, without going through a + // "variables" PUT first: that endpoint's own full-replace semantics would + // otherwise already drop the attachment this test wants to see preserved. + $this->attachProperties($host, $db); + + // A PUT on the base object endpoint (actionName 'index', not 'variables') that + // happens to carry a partial vars map must replace values, not drop the + // property attachments set up above. + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $host, + [self::ENV_KEY => 'staging'], + 'index', + 'PUT', + false + )); + + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + $this->assertEquals('staging', $host->vars()->get(self::ENV_KEY)->getValue()); + + $dba = $db->getDbAdapter(); + $count = $dba->fetchOne( + $dba->select() + ->from('icinga_host_property', ['COUNT(*)']) + ->where( + 'host_uuid = ?', + DbUtil::quoteBinaryCompat($host->get('uuid'), $dba) + ) + ); + $this->assertEquals( + 2, + (int) $count, + 'A base-object PUT must not remove the property attachments set up before it' + ); + } + + public function testVariablesPutPreservesRequiredFlagOnReattachment(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->createTemplate($db); + $this->attachProperties($host, $db, [self::ENV_KEY]); + + $this->assertEquals( + 'y', + $this->fetchRequiredFlag($host, $db, self::ENV_KEY), + 'attachProperties() must have set up the required flag this test relies on' + ); + + // A PUT on the "variables" endpoint wipes and recreates every direct property + // attachment; a still-present property must not lose its required flag. + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $host, + [self::ENV_KEY => 'production', self::MYSQL_KEY => (object) ['host' => 'db-primary']], + 'variables', + 'PUT', + false + )); + + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + $this->assertEquals( + 'y', + $this->fetchRequiredFlag($host, $db, self::ENV_KEY), + 'A PUT that replaces values must not clear the required flag of a still-present property' + ); + $this->assertEquals( + 'n', + $this->fetchRequiredFlag($host, $db, self::MYSQL_KEY), + 'A property that was never required must not become required as a side effect' + ); + } + + private function createTemplate($db): IcingaHost + { + if (IcingaHost::exists(self::TEMPLATE_NAME, $db)) { + IcingaHost::load(self::TEMPLATE_NAME, $db)->delete(); + } + + $host = IcingaHost::create([ + 'object_name' => self::TEMPLATE_NAME, + 'object_type' => 'template', + ]); + $host->store($db); + + return $host; + } + + private function attachProperties(IcingaHost $host, $db, array $requiredKeys = []): void + { + $dba = $db->getDbAdapter(); + + $stringProperty = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => self::ENV_KEY, + 'value_type' => 'string', + 'label' => 'Environment', + ], $db); + $stringProperty->store(); + + $dictProperty = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => self::MYSQL_KEY, + 'value_type' => 'fixed-dictionary', + 'label' => 'MySQL settings', + ], $db); + $dictProperty->store(); + + foreach ([self::ENV_KEY => $stringProperty, self::MYSQL_KEY => $dictProperty] as $key => $property) { + $dba->insert('icinga_host_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($property->get('uuid'), $dba), + 'host_uuid' => DbUtil::quoteBinaryCompat($host->get('uuid'), $dba), + 'required' => in_array($key, $requiredKeys, true) ? 'y' : 'n', + ]); + } + } + + private function fetchRequiredFlag(IcingaHost $host, $db, string $key): string + { + $dba = $db->getDbAdapter(); + + return $dba->fetchOne( + $dba->select() + ->from(['iop' => 'icinga_host_property'], ['required']) + ->join(['dp' => 'director_property'], 'dp.uuid = iop.property_uuid', []) + ->where('dp.key_name = ?', $key) + ->where( + 'iop.host_uuid = ?', + DbUtil::quoteBinaryCompat($host->get('uuid'), $dba) + ) + ); + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + if (IcingaHost::exists(self::TEMPLATE_NAME, $db)) { + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + $dba->delete( + 'icinga_host_property', + $dba->quoteInto( + 'host_uuid = ?', + DbUtil::quoteBinaryCompat(DbUtil::binaryResult($host->get('uuid')), $dba) + ) + ); + $host->delete(); + } + + $dba->delete('director_property', $dba->quoteInto('key_name IN (?)', [self::ENV_KEY, self::MYSQL_KEY])); + } + + parent::tearDown(); + } +} diff --git a/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php b/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php new file mode 100644 index 000000000..0554010ee --- /dev/null +++ b/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php @@ -0,0 +1,152 @@ +skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + if (IcingaHost::exists(self::TEMPLATE_NAME, $db)) { + IcingaHost::load(self::TEMPLATE_NAME, $db)->delete(); + } + + $host = IcingaHost::create([ + 'object_name' => self::TEMPLATE_NAME, + 'object_type' => 'template', + 'display_name' => 'Webserver Template', + ]); + $host->store($db); + + $dictProperty = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => self::DB_CONNECTION_KEY, + 'value_type' => 'fixed-dictionary', + 'label' => 'Database connection', + ], $db); + $dictProperty->store(); + + $dba = $db->getDbAdapter(); + $dba->insert('icinga_host_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($dictProperty->get('uuid'), $dba), + 'host_uuid' => DbUtil::quoteBinaryCompat($host->get('uuid'), $dba), + ]); + + $handler = new IcingaObjectHandler(new Request(), new Response(), $db); + $method = new ReflectionMethod($handler, 'persistObjectAndApplyVars'); + $method->setAccessible(true); + + $writeRequest = new IcingaObjectWriteRequest( + $host, + ['display_name' => 'Webserver Template (renamed)'], + 'host', + 'index', + 'PUT', + false, + // A fixed-dictionary custom variable given a plain (non-associative) + // array is a type mismatch, mirroring CustomVariableValueApplierTest's + // own testFailedValidationRollsBackFullReplace scenario. + [self::DB_CONNECTION_KEY => ['not', 'a', 'dictionary']], + false, + new UrlParams() + ); + + $threw = false; + try { + $db->runFailSafeTransaction(function () use ($method, $handler, $writeRequest) { + $method->invoke($handler, $writeRequest); + }); + } catch (Throwable $e) { + $threw = true; + $this->assertInstanceOf(InvalidArgumentException::class, $e); + } + + $this->assertTrue($threw, 'The mismatched custom variable type must raise an exception'); + + $reloaded = IcingaHost::load(self::TEMPLATE_NAME, $db); + $this->assertEquals( + 'Webserver Template', + $reloaded->get('display_name'), + 'The object property change must not survive a custom-variable validation ' + . 'failure that happens in the same request' + ); + $this->assertNull( + $reloaded->vars()->get(self::DB_CONNECTION_KEY), + 'The rejected custom variable must not have been persisted either' + ); + } + + public function testDeleteIsAllowedOnTheIndexAction(): void + { + IcingaObjectHandler::assertDeleteAllowed('index'); + $this->addToAssertionCount(1); + } + + public function testDeleteIsRejectedOnTheVariablesAction(): void + { + $this->expectException(NotFoundError::class); + IcingaObjectHandler::assertDeleteAllowed('variables'); + } + + public function testJsonObjectBodyIsAccepted(): void + { + IcingaObjectHandler::assertJsonBodyIsObject((object) ['environment' => 'production']); + $this->addToAssertionCount(1); + } + + public function testJsonArrayBodyIsRejected(): void + { + // InvalidArgumentException is what processApiRequest() maps to HTTP 422, + // the same status every other malformed override in this handler returns. + $this->expectException(InvalidArgumentException::class); + IcingaObjectHandler::assertJsonBodyIsObject([1, 2, 3]); + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + if (IcingaHost::exists(self::TEMPLATE_NAME, $db)) { + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + $dba->delete( + 'icinga_host_property', + $dba->quoteInto( + 'host_uuid = ?', + DbUtil::quoteBinaryCompat(DbUtil::binaryResult($host->get('uuid')), $dba) + ) + ); + $host->delete(); + } + + $dba->delete('director_property', $dba->quoteInto('key_name = ?', self::DB_CONNECTION_KEY)); + } + + parent::tearDown(); + } +} diff --git a/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php b/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php new file mode 100644 index 000000000..2c9c32cec --- /dev/null +++ b/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php @@ -0,0 +1,94 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Web\Form\Element; + +use Icinga\Module\Director\Web\Form\Element\SensitiveElement; +use PHPUnit\Framework\TestCase; + +class SensitiveElementTest extends TestCase +{ + public function testGetValueReturnsEmptyStringWhenNothingWasEverEntered(): void + { + $element = new SensitiveElement('api_token'); + + $this->assertSame('', $element->getValue()); + } + + public function testGetValueReturnsTheEnteredValue(): void + { + $element = new SensitiveElement('api_token'); + $element->setValue('sk_live_4f8a1c9d2b7e'); + + $this->assertSame('sk_live_4f8a1c9d2b7e', $element->getValue()); + } + + public function testWasSubmittedUnchangedIsFalseWhenNothingWasEverEntered(): void + { + $element = new SensitiveElement('api_token'); + + $this->assertFalse($element->wasSubmittedUnchanged()); + } + + public function testWasSubmittedUnchangedIsFalseWhenExplicitlyEmptied(): void + { + $element = new SensitiveElement('api_token'); + $element->setValue(''); + + $this->assertFalse($element->wasSubmittedUnchanged()); + } + + public function testWasSubmittedUnchangedIsFalseWhenGivenANewValue(): void + { + $element = new SensitiveElement('api_token'); + $element->setValue('sk_live_9d3e7b2a6f10'); + + $this->assertFalse($element->wasSubmittedUnchanged()); + } + + public function testWasSubmittedUnchangedIsTrueWhenTheDummyPasswordSentinelComesBack(): void + { + $element = new SensitiveElement('api_token'); + $element->setValue(SensitiveElement::DUMMYPASSWORD); + + $this->assertTrue($element->wasSubmittedUnchanged()); + } + + public function testRenderedValueAttributeMasksTheDummyPasswordSentinel(): void + { + // DictionaryItem::prepare() always sends DUMMYPASSWORD instead of the real + // secret, so this is what a fresh page load looks like, and also what a field + // left untouched on resubmit looks like. Both must show up masked. + $element = new SensitiveElement('api_token'); + $element->setValue(SensitiveElement::DUMMYPASSWORD); + + $html = (string) $element; + + $this->assertStringContainsString(SensitiveElement::DUMMYPASSWORD, $html); + } + + public function testRenderedValueAttributeShowsAFreshlyTypedValue(): void + { + // Any value other than the sentinel is something the user just typed, so we show + // it as-is. If we masked it too, saving again would look like "left unchanged" + // and quietly bring back the old secret. + $element = new SensitiveElement('api_token'); + $element->setValue('sk_live_00ff11ee22dd'); + + $html = (string) $element; + + $this->assertStringContainsString('value="sk_live_00ff11ee22dd"', $html); + } + + public function testRenderedValueAttributeIsAbsentWhenExplicitlyEmptied(): void + { + $element = new SensitiveElement('api_token'); + $element->setValue(''); + + $html = (string) $element; + + $this->assertStringNotContainsString(SensitiveElement::DUMMYPASSWORD, $html); + } +}