From aa4a3a528e002f8d1d2798f907775a64d92296f6 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 29 May 2026 14:21:40 +0200 Subject: [PATCH 001/221] Add database migration for DirectorProperty and custom variable support Introduce upgrade_193 for both MySQL and PostgreSQL, creating the director_property, director_property_datalist, icinga__property, and related tables that back the new custom variable system. Also fixes a duplicate constraint name in the MySQL migration and backfills the director_datafield_category constraint. --- schema/mysql-migrations/upgrade_193.sql | 169 ++++++++++++++++++++++ schema/mysql.sql | 167 +++++++++++++++++++++- schema/pgsql-migrations/upgrade_193.sql | 180 ++++++++++++++++++++++++ schema/pgsql.sql | 167 +++++++++++++++++++++- 4 files changed, 676 insertions(+), 7 deletions(-) create mode 100644 schema/mysql-migrations/upgrade_193.sql create mode 100644 schema/pgsql-migrations/upgrade_193.sql diff --git a/schema/mysql-migrations/upgrade_193.sql b/schema/mysql-migrations/upgrade_193.sql new file mode 100644 index 000000000..0fcaf6a00 --- /dev/null +++ b/schema/mysql-migrations/upgrade_193.sql @@ -0,0 +1,169 @@ +CREATE TABLE director_property ( + uuid binary(16) NOT NULL, + parent_uuid binary(16) DEFAULT NULL, + key_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + label varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + value_type enum( + 'string', + 'number', + 'bool', + 'fixed-array', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary', + 'datalist-strict', + 'datalist-non-strict' + ) COLLATE utf8mb4_unicode_ci NOT NULL, + category_id INT(10) UNSIGNED DEFAULT NULL, + description text, + parent_uuid_v BINARY(16) AS (COALESCE(parent_uuid, 0x00000000000000000000000000000000)) STORED, + PRIMARY KEY (uuid), + UNIQUE INDEX unique_name_parent_uuid (key_name, parent_uuid_v), + CONSTRAINT director_property_category + FOREIGN KEY category (category_id) + REFERENCES director_datafield_category (id) + ON DELETE RESTRICT + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_host_property ( + host_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (host_uuid, property_uuid), + CONSTRAINT icinga_host_property_host + FOREIGN KEY host(host_uuid) + REFERENCES icinga_host (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_service_property ( + service_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (service_uuid, property_uuid), + CONSTRAINT icinga_service_property_service + FOREIGN KEY service(service_uuid) + REFERENCES icinga_service (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_command_property ( + command_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (command_uuid, property_uuid), + CONSTRAINT icinga_command_property_command + FOREIGN KEY command(command_uuid) + REFERENCES icinga_command (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_command_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_notification_property ( + notification_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (notification_uuid, property_uuid), + CONSTRAINT icinga_notification_property_notification + FOREIGN KEY notification(notification_uuid) + REFERENCES icinga_notification (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_notification_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_service_set_property ( + service_set_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (service_set_uuid, property_uuid), + CONSTRAINT icinga_service_set_property_service_set + FOREIGN KEY service_set(service_set_uuid) + REFERENCES icinga_service_set (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_set_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_user_property ( + user_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (user_uuid, property_uuid), + CONSTRAINT icinga_user_property_user + FOREIGN KEY user (user_uuid) + REFERENCES icinga_user (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_user_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +ALTER TABLE director_datalist + ADD UNIQUE KEY (uuid); + +CREATE TABLE director_property_datalist ( + list_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + CONSTRAINT director_list_property_list + FOREIGN KEY list (list_uuid) + REFERENCES director_datalist (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT director_property_list_property + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin; + +ALTER TABLE icinga_host_var + ADD COLUMN property_uuid binary(16) DEFAULT NULL; + +ALTER TABLE icinga_service_var + ADD COLUMN property_uuid binary(16) DEFAULT NULL; + +ALTER TABLE icinga_command_var + ADD COLUMN property_uuid binary(16) DEFAULT NULL; + +ALTER TABLE icinga_notification_var + ADD COLUMN property_uuid binary(16) DEFAULT NULL; + +ALTER TABLE icinga_service_set_var + ADD COLUMN property_uuid binary(16) DEFAULT NULL; + +ALTER TABLE icinga_user_var + ADD COLUMN property_uuid binary(16) DEFAULT NULL; + +INSERT INTO director_schema_migration +(schema_version, migration_time) +VALUES (193, NOW()); diff --git a/schema/mysql.sql b/schema/mysql.sql index 769a2428d..937eb3f85 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -461,6 +461,7 @@ CREATE TABLE icinga_command_var ( varvalue TEXT DEFAULT NULL, format ENUM('string', 'expression', 'json') NOT NULL DEFAULT 'string', checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(16) DEFAULT NULL, PRIMARY KEY (command_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), @@ -651,20 +652,69 @@ CREATE TABLE icinga_host_field ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE director_property ( + uuid varbinary(16) NOT NULL, + parent_uuid varbinary(16) NULL DEFAULT NULL, + key_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + label varchar(255) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, + description text DEFAULT NULL, + value_type enum( + 'string', + 'number', + 'bool', + 'fixed-array', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary', + 'datalist-strict', + 'datalist-non-strict' + ) COLLATE utf8mb4_unicode_ci NOT NULL, + category_id INT(10) UNSIGNED DEFAULT NULL, + parent_uuid_v BINARY(16) AS (COALESCE(parent_uuid, 0x00000000000000000000000000000000)) STORED, + PRIMARY KEY (uuid), + UNIQUE INDEX unique_name_parent_uuid (key_name, parent_uuid_v), + CONSTRAINT director_property_category + FOREIGN KEY category (category_id) + REFERENCES director_datafield_category (id) + ON DELETE RESTRICT + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE icinga_host_property ( + host_uuid varbinary(16) NOT NULL, + property_uuid varbinary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (host_uuid, property_uuid), + CONSTRAINT icinga_host_property_host + FOREIGN KEY host(host_uuid) + REFERENCES icinga_host (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + CREATE TABLE icinga_host_var ( host_id INT(10) UNSIGNED NOT NULL, varname VARCHAR(255) NOT NULL COLLATE utf8_bin, varvalue MEDIUMTEXT DEFAULT NULL, format enum ('string', 'json', 'expression'), -- immer string vorerst checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(16) DEFAULT NULL, PRIMARY KEY (host_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), CONSTRAINT icinga_host_var_host - FOREIGN KEY host (host_id) - REFERENCES icinga_host (id) - ON DELETE CASCADE - ON UPDATE CASCADE + FOREIGN KEY host (host_id) + REFERENCES icinga_host (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_var_property_uuid + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ALTER TABLE icinga_host_template_choice @@ -810,6 +860,7 @@ CREATE TABLE icinga_service_var ( varvalue TEXT DEFAULT NULL, format enum ('string', 'json', 'expression'), checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(16) DEFAULT NULL, PRIMARY KEY (service_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), @@ -901,6 +952,7 @@ CREATE TABLE icinga_service_set_var ( varvalue TEXT DEFAULT NULL, format ENUM('string', 'expression', 'json') NOT NULL DEFAULT 'string', checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(16) DEFAULT NULL, PRIMARY KEY (service_set_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), @@ -1150,6 +1202,7 @@ CREATE TABLE icinga_user_var ( varvalue TEXT DEFAULT NULL, format ENUM('string', 'json', 'expression') NOT NULL DEFAULT 'string', checksum VARBINARY(20) DEFAULT NULL, + property_uuid VARBINARY(16) DEFAULT NULL, PRIMARY KEY (user_id, varname), INDEX search_idx (varname), INDEX checksum (checksum), @@ -1301,6 +1354,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), @@ -2464,6 +2518,109 @@ CREATE TABLE icinga_usergroup_user_resolved ON UPDATE CASCADE ) ENGINE = InnoDB DEFAULT CHARSET = utf8; +CREATE TABLE icinga_service_property ( + service_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (service_uuid, property_uuid), + CONSTRAINT icinga_service_property_service + FOREIGN KEY service(service_uuid) + REFERENCES icinga_service (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_command_property ( + command_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (command_uuid, property_uuid), + CONSTRAINT icinga_command_property_command + FOREIGN KEY command(command_uuid) + REFERENCES icinga_command (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_command_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_notification_property ( + notification_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (notification_uuid, property_uuid), + CONSTRAINT icinga_notification_property_notification + FOREIGN KEY notification(notification_uuid) + REFERENCES icinga_notification (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_notification_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_service_set_property ( + service_set_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (service_set_uuid, property_uuid), + CONSTRAINT icinga_service_set_property_service_set + FOREIGN KEY service_set(service_set_uuid) + REFERENCES icinga_service_set (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_set_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_user_property ( + user_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (user_uuid, property_uuid), + CONSTRAINT icinga_user_property_user + FOREIGN KEY user (user_uuid) + REFERENCES icinga_user (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_user_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +ALTER TABLE director_datalist + ADD UNIQUE KEY (uuid); + +CREATE TABLE director_property_datalist ( + list_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + CONSTRAINT director_list_property_list + FOREIGN KEY list (list_uuid) + REFERENCES director_datalist (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT director_property_list_property + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin; + INSERT INTO director_schema_migration (schema_version, migration_time) - VALUES (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..e0af90fcb --- /dev/null +++ b/schema/pgsql-migrations/upgrade_193.sql @@ -0,0 +1,180 @@ +CREATE TYPE enum_property_value_type AS ENUM( + 'string', + 'number', + 'bool', + 'fixed-array', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary', + 'datalist-strict', + 'datalist-non-strict' +); + +CREATE TABLE director_property ( + uuid bytea CHECK(LENGTH(uuid) = 16) NOT NULL, + parent_uuid bytea CHECK(LENGTH(parent_uuid) = 16) DEFAULT NULL, + key_name character varying(255) NOT NULL, + label character varying(255) DEFAULT NULL, + description text DEFAULT NULL, + value_type enum_property_value_type NOT NULL, + category_id integer DEFAULT NULL, + PRIMARY KEY (uuid), + CONSTRAINT director_property_category + FOREIGN KEY (category_id) + REFERENCES director_datafield_category (id) + ON DELETE RESTRICT + ON UPDATE CASCADE +); + +-- Unique key_name at root level (no parent) +CREATE UNIQUE INDEX unique_property_name_root + ON director_property (key_name) + WHERE parent_uuid IS NULL; + +-- Unique (key_name, parent_uuid) for nested properties +CREATE UNIQUE INDEX unique_property_name_parent + ON director_property (key_name, parent_uuid) + WHERE parent_uuid IS NOT NULL; + +CREATE TABLE icinga_host_property ( + host_uuid bytea CHECK(LENGTH(host_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (host_uuid, property_uuid), + CONSTRAINT icinga_host_property_host + FOREIGN KEY (host_uuid) + REFERENCES icinga_host (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE icinga_service_property ( + service_uuid bytea CHECK(LENGTH(service_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (service_uuid, property_uuid), + CONSTRAINT icinga_service_property_service + FOREIGN KEY (service_uuid) + REFERENCES icinga_service (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE icinga_command_property ( + command_uuid bytea CHECK(LENGTH(command_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (command_uuid, property_uuid), + CONSTRAINT icinga_command_property_command + FOREIGN KEY (command_uuid) + REFERENCES icinga_command (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_command_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE icinga_notification_property ( + notification_uuid bytea CHECK(LENGTH(notification_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (notification_uuid, property_uuid), + CONSTRAINT icinga_notification_property_notification + FOREIGN KEY (notification_uuid) + REFERENCES icinga_notification (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_notification_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE icinga_service_set_property ( + service_set_uuid bytea CHECK(LENGTH(service_set_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (service_set_uuid, property_uuid), + CONSTRAINT icinga_service_set_property_service_set + FOREIGN KEY (service_set_uuid) + REFERENCES icinga_service_set (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_set_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE icinga_user_property ( + user_uuid bytea CHECK(LENGTH(user_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (user_uuid, property_uuid), + CONSTRAINT icinga_user_property_user + FOREIGN KEY (user_uuid) + REFERENCES icinga_user (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_user_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS director_datalist_uuid_unique + ON director_datalist (uuid); + +CREATE TABLE director_property_datalist ( + list_uuid bytea CHECK(LENGTH(list_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + CONSTRAINT director_list_property_list + FOREIGN KEY (list_uuid) + REFERENCES director_datalist (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT director_property_list_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +ALTER TABLE icinga_host_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_service_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_command_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_notification_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_service_set_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +ALTER TABLE icinga_user_var + ADD COLUMN property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL; + +INSERT INTO director_schema_migration + (schema_version, migration_time) + VALUES (193, NOW()); diff --git a/schema/pgsql.sql b/schema/pgsql.sql index d4ca94b6a..8d8eca65f 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -17,6 +17,18 @@ CREATE TYPE enum_merge_behaviour AS ENUM('set', 'add', 'substract', 'override'); CREATE TYPE enum_set_merge_behaviour AS ENUM('override', 'extend', 'blacklist'); CREATE TYPE enum_command_object_type AS ENUM('object', 'template', 'external_object'); CREATE TYPE enum_apply_object_type AS ENUM('object', 'template', 'apply', 'external_object'); +CREATE TYPE enum_property_value_type AS ENUM( + 'string', + 'number', + 'bool', + 'fixed-array', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary', + 'datalist-strict', + 'datalist-non-strict' +); + CREATE TYPE enum_state_name AS ENUM('OK', 'Warning', 'Critical', 'Unknown', 'Up', 'Down'); CREATE TYPE enum_type_name AS ENUM('DowntimeStart', 'DowntimeEnd', 'DowntimeRemoved', 'Custom', 'Acknowledgement', 'Problem', 'Recovery', 'FlappingStart', 'FlappingEnd'); CREATE TYPE enum_sync_rule_object_type AS ENUM( @@ -246,7 +258,7 @@ CREATE INDEX start_time_idx ON director_deployment_log (start_time); CREATE TABLE director_datalist ( id serial, - uuid bytea CHECK(LENGTH(uuid) = 16) NOT NULL, + uuid bytea UNIQUE CHECK(LENGTH(uuid) = 16) NOT NULL, list_name character varying(255) NOT NULL, owner character varying(255) NOT NULL, PRIMARY KEY (id) @@ -318,6 +330,46 @@ CREATE TABLE director_datafield_setting ( CREATE INDEX director_datafield_datafield ON director_datafield_setting (datafield_id); +CREATE TABLE director_property ( + uuid bytea CHECK(LENGTH(uuid) = 16) NOT NULL, + parent_uuid bytea CHECK(LENGTH(parent_uuid) = 16) DEFAULT NULL, + key_name character varying(255) NOT NULL, + label character varying(255) DEFAULT NULL, + description text DEFAULT NULL, + value_type enum_property_value_type NOT NULL, + category_id integer DEFAULT NULL, + PRIMARY KEY (uuid), + CONSTRAINT director_property_category + FOREIGN KEY (category_id) + REFERENCES director_datafield_category (id) + ON DELETE RESTRICT + ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX unique_property_name_root + ON director_property (key_name) + WHERE parent_uuid IS NULL; + +CREATE UNIQUE INDEX unique_property_name_parent + ON director_property (key_name, parent_uuid) + WHERE parent_uuid IS NOT NULL; + +CREATE TABLE director_property_datalist ( + list_uuid bytea CHECK(LENGTH(list_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + CONSTRAINT director_list_property_list + FOREIGN KEY (list_uuid) + REFERENCES director_datalist (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT director_property_list_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + CREATE TABLE director_schema_migration ( schema_version SMALLINT NOT NULL, @@ -572,6 +624,22 @@ 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 TABLE icinga_command_var ( command_id integer NOT NULL, @@ -579,6 +647,7 @@ 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) @@ -802,12 +871,30 @@ CREATE INDEX host_field_datafield ON icinga_host_field (datafield_id); COMMENT ON COLUMN icinga_host_field.host_id IS 'Makes only sense for templates'; +CREATE TABLE icinga_host_property ( + host_uuid bytea CHECK(LENGTH(host_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (host_uuid, property_uuid), + CONSTRAINT icinga_host_property_host + FOREIGN KEY (host_uuid) + REFERENCES icinga_host (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + CREATE TABLE icinga_host_var ( host_id integer NOT NULL, checksum bytea DEFAULT NULL UNIQUE CHECK(LENGTH(checksum) = 20), varname character varying(255) NOT NULL, varvalue text DEFAULT NULL, format enum_property_format, -- immer string vorerst + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (host_id, varname), CONSTRAINT icinga_host_var_host FOREIGN KEY (host_id) @@ -975,12 +1062,31 @@ CREATE INDEX service_inheritance_service ON icinga_service_inheritance (service_ CREATE INDEX service_inheritance_service_parent ON icinga_service_inheritance (parent_service_id); +CREATE TABLE icinga_service_property ( + service_uuid bytea CHECK(LENGTH(service_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (service_uuid, property_uuid), + CONSTRAINT icinga_service_property_service + FOREIGN KEY (service_uuid) + REFERENCES icinga_service (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + CREATE TABLE icinga_service_var ( service_id integer NOT NULL, checksum bytea DEFAULT NULL UNIQUE CHECK(LENGTH(checksum) = 20), varname character varying(255) NOT NULL, varvalue text DEFAULT NULL, format enum_property_format, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (service_id, varname), CONSTRAINT icinga_service_var_service FOREIGN KEY (service_id) @@ -1088,12 +1194,31 @@ CREATE INDEX service_set_inheritance_set ON icinga_service_set_inheritance (serv CREATE INDEX service_set_inheritance_parent ON icinga_service_set_inheritance (parent_service_set_id); +CREATE TABLE icinga_service_set_property ( + service_set_uuid bytea CHECK(LENGTH(service_set_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (service_set_uuid, property_uuid), + CONSTRAINT icinga_service_set_property_service_set + FOREIGN KEY (service_set_uuid) + REFERENCES icinga_service_set (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_service_set_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + CREATE TABLE icinga_service_set_var ( service_set_id integer NOT NULL, checksum bytea DEFAULT NULL UNIQUE CHECK(LENGTH(checksum) = 20), varname character varying(255) NOT NULL, varvalue text DEFAULT NULL, format enum_property_format NOT NULL DEFAULT 'string', + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (service_set_id, varname), CONSTRAINT icinga_service_set_var_service_set FOREIGN KEY (service_set_id) @@ -1370,6 +1495,7 @@ CREATE TABLE icinga_user_var ( varname character varying(255) NOT NULL, varvalue text DEFAULT NULL, format enum_property_format NOT NULL DEFAULT 'string', + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (user_id, varname), CONSTRAINT icinga_user_var_user FOREIGN KEY (user_id) @@ -1407,6 +1533,24 @@ CREATE INDEX user_field_datafield ON icinga_user_field (datafield_id); COMMENT ON COLUMN icinga_user_field.user_id IS 'Makes only sense for templates'; +CREATE TABLE icinga_user_property ( + user_uuid bytea CHECK(LENGTH(user_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (user_uuid, property_uuid), + CONSTRAINT icinga_user_property_user + FOREIGN KEY (user_uuid) + REFERENCES icinga_user (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_user_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + CREATE TABLE icinga_usergroup ( id serial, uuid bytea UNIQUE CHECK(LENGTH(uuid) = 16), @@ -1823,6 +1967,7 @@ CREATE TABLE icinga_notification_var ( varname VARCHAR(255) NOT NULL, varvalue TEXT DEFAULT NULL, format enum_property_format, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) DEFAULT NULL, PRIMARY KEY (notification_id, varname), CONSTRAINT icinga_notification_var_notification FOREIGN KEY (notification_id) @@ -1859,6 +2004,24 @@ CREATE INDEX notification_field_datafield ON icinga_notification_field (datafiel COMMENT ON COLUMN icinga_notification_field.notification_id IS 'Makes only sense for templates'; +CREATE TABLE icinga_notification_property ( + notification_uuid bytea CHECK(LENGTH(notification_uuid) = 16) NOT NULL, + property_uuid bytea CHECK(LENGTH(property_uuid) = 16) NOT NULL, + required enum_boolean NOT NULL DEFAULT 'n', + PRIMARY KEY (notification_uuid, property_uuid), + CONSTRAINT icinga_notification_property_notification + FOREIGN KEY (notification_uuid) + REFERENCES icinga_notification (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_notification_custom_property + FOREIGN KEY (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + CREATE TABLE icinga_notification_inheritance ( notification_id integer NOT NULL, parent_notification_id integer NOT NULL, @@ -2803,4 +2966,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()); From e87134f4c2f8e3a75f2954ec6c42226e91e5b3af Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 29 May 2026 14:21:58 +0200 Subject: [PATCH 002/221] Add model support for new custom variable Key changes: - DirectorProperty: CRUD, inheritance, and apply-for rule support on all object types (host, service, command, notification, dependency, user). The concept extends the existing data-fields model with rich, structured variable types. Supported types: string, number, boolean, fixed-array, dynamic-array, datalist-strict, datalist-non-strict, fixed-dictionary, and dynamic-dictionary (one level of nesting) - IcingaObject: generic wiring so all object types resolve and render their assigned DirectorProperties - IcingaService: apply-for rule handling for array and dictionary types, including LIKE-based PostgreSQL filtering - CustomVariables/CustomVariable*: render dictionaries and arrays to valid Icinga 2 DSL; rewrite renderSingleVar for efficiency; add PostgreSQL binary UUID normalization via DbUtil - IcingaConfigHelper: extend to render nested dictionaries; fix isValidMacroName and in_array null-safety - IcingaConfig: clear overriddenVar after it is read in the override template - CustomVarRenderer: display structured variables via the icingadb hook - CustomVariableReferenceLoader: resolve references for export/import - CustomVariables: Extend linking of custom variables to other objects --- .../CustomVariable/CustomVariable.php | 53 ++- .../CustomVariable/CustomVariableArray.php | 2 +- .../CustomVariableDictionary.php | 10 +- .../CustomVariable/CustomVariableString.php | 2 +- .../CustomVariable/CustomVariables.php | 134 +++++- .../Data/CustomVariableReferenceLoader.php | 58 +++ library/Director/Db/DbUtil.php | 20 + .../Director/IcingaConfig/IcingaConfig.php | 10 +- .../IcingaConfig/IcingaConfigHelper.php | 55 ++- .../Director/Objects/DirectorDatafield.php | 37 +- library/Director/Objects/DirectorDatalist.php | 15 + library/Director/Objects/DirectorProperty.php | 430 ++++++++++++++++++ library/Director/Objects/IcingaCommand.php | 2 + library/Director/Objects/IcingaHost.php | 2 + library/Director/Objects/IcingaHostVar.php | 1 + .../Director/Objects/IcingaNotification.php | 2 + library/Director/Objects/IcingaObject.php | 108 ++++- library/Director/Objects/IcingaService.php | 123 ++++- library/Director/Objects/IcingaUser.php | 2 + .../Icingadb/CustomVarRenderer.php | 304 ++++++++++--- library/Director/Resolver/TemplateTree.php | 9 +- library/Director/Web/ObjectPreview.php | 2 +- .../Table/IcingaHostAppliedServicesTable.php | 17 +- .../Table/IcingaServiceSetServiceTable.php | 12 + .../Web/Table/ObjectsTableService.php | 16 +- .../Web/Widget/CustomVarFieldsTable.php | 61 +++ .../Web/Widget/CustomVarObjectList.php | 69 +++ .../Director/Web/Widget/CustomVarRenderer.php | 68 +++ 28 files changed, 1502 insertions(+), 122 deletions(-) create mode 100644 library/Director/Data/CustomVariableReferenceLoader.php create mode 100644 library/Director/Objects/DirectorProperty.php create mode 100644 library/Director/Web/Widget/CustomVarFieldsTable.php create mode 100644 library/Director/Web/Widget/CustomVarObjectList.php create mode 100644 library/Director/Web/Widget/CustomVarRenderer.php 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..e7bea4e7a 100644 --- a/library/Director/CustomVariable/CustomVariableDictionary.php +++ b/library/Director/CustomVariable/CustomVariableDictionary.php @@ -5,11 +5,12 @@ use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; use Countable; +use Icinga\Module\Director\Objects\IcingaObject; class CustomVariableDictionary extends CustomVariable implements Countable { /** @var CustomVariable[] */ - protected $value; + protected $value = []; public function equals(CustomVariable $var) { @@ -66,7 +67,6 @@ public function setValue($value) public function getValue() { $ret = (object) array(); - ksort($this->value); foreach ($this->value as $key => $var) { $ret->$key = $var->getValue(); @@ -119,6 +119,12 @@ public function getInternalValue($key) public function toConfigString($renderExpressions = false) { + if ($this->whiteList !== null) { + foreach ($this->value as $key => $value) { + $this->value[$key] = $value->setWhiteList($this->whiteList); + } + } + // TODO return c::renderDictionary($this->value); } diff --git a/library/Director/CustomVariable/CustomVariableString.php b/library/Director/CustomVariable/CustomVariableString.php index 2d509681c..a978b2c9f 100644 --- a/library/Director/CustomVariable/CustomVariableString.php +++ b/library/Director/CustomVariable/CustomVariableString.php @@ -46,7 +46,7 @@ public function flatten(array &$flat, $prefix) public function toConfigString($renderExpressions = false) { if ($renderExpressions) { - return c::renderStringWithVariables($this->getValue(), ['config']); + return c::renderStringWithVariables($this->getValue(), $this->whiteList); } else { return c::renderString($this->getValue()); } diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php index bb0b44b34..ceab1d161 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,9 @@ class CustomVariables implements Iterator, Countable, IcingaConfigRenderer protected $idx = array(); + /** @var array Array of values to be used as whitelist */ + private $whiteList = []; + protected static $allTables = array( 'icinga_command_var', 'icinga_host_var', @@ -159,6 +163,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 +192,18 @@ public static function loadForStoredObject(IcingaObject $object) { $db = $object->getDb(); + $type = $object->getShortTableName(); + $columns = [ + 'v.' . $type . '_id', + 'v.varname', + 'v.varvalue', + 'v.format' + ]; + + $columns[] = 'property_uuid'; $query = $db->select()->from( - array('v' => $object->getVarsTableName()), - array( - 'v.varname', - 'v.varvalue', - 'v.format', - ) + ['v' => $object->getVarsTableName()], + $columns )->where(sprintf('v.%s = ?', $object->getVarsIdColumn()), $object->get('id')); $vars = new CustomVariables(); @@ -213,17 +236,22 @@ public function storeToDb(IcingaObject $object) foreach ($this->vars as $var) { + $uuid = $var->getUuid()?->getBytes(); if ($var->isNew()) { - $db->insert( - $table, - array( - $foreignColumn => $foreignId, - 'varname' => $var->getKey(), - 'varvalue' => $var->getDbValue(), - 'format' => $var->getDbFormat() - ) - ); + $row = [ + $foreignColumn => $foreignId, + 'varname' => $var->getKey(), + 'varvalue' => $var->getDbValue(), + 'format' => $var->getDbFormat() + ]; + + if ($uuid) { + $row['property_uuid'] = Db\DbUtil::quoteBinaryCompat($uuid, $db); + } + + $db->insert($table, $row); $var->setLoadedFromDb(); + continue; } @@ -233,12 +261,18 @@ public function storeToDb(IcingaObject $object) if ($var->hasBeenDeleted()) { $db->delete($table, $where); } elseif ($var->hasBeenModified()) { + $data = [ + 'varvalue' => $var->getDbValue(), + 'format' => $var->getDbFormat() + ]; + + if ($uuid) { + $data['property_uuid'] = Db\DbUtil::quoteBinaryCompat($uuid, $db); + } + $db->update( $table, - array( - 'varvalue' => $var->getDbValue(), - 'format' => $var->getDbFormat() - ), + $data, $where ); } @@ -341,13 +375,13 @@ public function setOverrideKeyName($name) return $this; } - public function toConfigString($renderExpressions = false) + public function toConfigString($renderExpressions = false, ?IcingaObject $object = null) { $out = ''; 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,20 +424,59 @@ 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) ); + } + + if ($object === null || empty($object->get('id')) || ! ($var instanceof CustomVariable)) { + return c::renderKeyValue( + $this->renderKeyName($key), + $var->toConfigStringPrefetchable($renderExpressions) + ); + } + + $type = $object->getShortTableName(); + $objectId = $object->get('id'); + $ids = $object->listAncestorIds(); + $ids[] = $objectId; + + $query = $object->getDb()->select()->from( + ['dp' => 'director_property'], + ['value_type'] + ) + ->join(['iop' => 'icinga_' . $type . '_property'], 'dp.uuid = iop.property_uuid', []) + ->join(['io' => 'icinga_' . $type], 'iop.' . $type . '_uuid = io.uuid', ['object_id' => 'io.id']) + ->join(['iov' => 'icinga_' . $type . '_var'], 'iov.' . $type . '_id = io.id', []) + ->where('dp.key_name = ?', $var->getKey()) + ->where('io.id IN (?)', $ids); + + $row = (array) $object->getDb()->fetchRow($query); + if ( + isset($row['value_type']) + && $row['value_type'] === 'dynamic-dictionary' + && (int) $objectId !== (int) $row['object_id'] + ) { + return c::renderKeyOperatorValue( + $this->renderKeyName($key), + '+=', + $var->toConfigStringPrefetchable($renderExpressions) + ); } else { return c::renderKeyValue( $this->renderKeyName($key), @@ -475,6 +548,23 @@ public function __unset($key) $this->refreshIndex(); } + /** + * Register the UUID of the given variable + * + * @param string $key + * @param UuidInterface $uuid + * + * @return $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/Data/CustomVariableReferenceLoader.php b/library/Director/Data/CustomVariableReferenceLoader.php new file mode 100644 index 000000000..33b74e8c7 --- /dev/null +++ b/library/Director/Data/CustomVariableReferenceLoader.php @@ -0,0 +1,58 @@ +db = $connection->getDbAdapter(); + } + + /** + * Load properties referenced by the object + * + * @param IcingaObject $object + * + * @return array + */ + public function loadFor(IcingaObject $object): array + { + $db = $this->db; + $uuid = Db\DbUtil::quoteBinaryCompat($object->get('uuid'), $db); + if ($uuid === null) { + return []; + } + + $type = $object->getShortTableName(); + $res = $db->fetchAll( + $db->select()->from(['f' => "icinga_{$type}_property"], [ + 'f.property_uuid', + ])->join(['df' => 'director_property'], 'df.uuid = f.property_uuid', []) + ->where("{$type}_uuid = ?", $uuid) + ->order('key_name ASC') + ); + + if (empty($res)) { + return []; + } + + foreach ($res as $key => $property) { + $propUuid = Db\DbUtil::binaryResult($property->property_uuid); + $property->property_uuid = Uuid::fromBytes( + $propUuid + )->toString(); + $res[$key] = $property; + } + + return $res; + } +} diff --git a/library/Director/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/IcingaConfig/IcingaConfig.php b/library/Director/IcingaConfig/IcingaConfig.php index a79bf3c79..c1140a515 100644 --- a/library/Director/IcingaConfig/IcingaConfig.php +++ b/library/Director/IcingaConfig/IcingaConfig.php @@ -566,10 +566,16 @@ protected function renderHostOverridableVars() globals.directorWarnOnceForServiceWithoutHost() } + var overridenVar = name + if (vars.overridenVar) { + overridenVar = vars.overridenVar + vars.remove("overridenVar") + } + if (vars) { - vars += host.vars[DirectorOverrideVars][name] + vars += host.vars[DirectorOverrideVars][overridenVar] } else { - vars = host.vars[DirectorOverrideVars][name] + vars = host.vars[DirectorOverrideVars][overridenVar] } } } diff --git a/library/Director/IcingaConfig/IcingaConfigHelper.php b/library/Director/IcingaConfig/IcingaConfigHelper.php index 5d44bfaee..d82debc54 100644 --- a/library/Director/IcingaConfig/IcingaConfigHelper.php +++ b/library/Director/IcingaConfig/IcingaConfigHelper.php @@ -2,6 +2,7 @@ namespace Icinga\Module\Director\IcingaConfig; +use Icinga\Module\Director\CustomVariable\CustomVariableString; use InvalidArgumentException; use function ctype_digit; @@ -70,6 +71,10 @@ public static function renderKeyValue($key, $value, $prefix = ' ') public static function renderKeyOperatorValue($key, $operator, $value, $prefix = ' ') { + if ($value instanceof CustomVariableString && ! empty($value->getWhiteList())) { + $value = $value->toConfigString(true); + } + $string = sprintf( "%s %s %s", $key, @@ -375,13 +380,40 @@ public static function stringHasMacro($string, $macroName = null) /** * Hint: this isn't complete, but let's restrict ourselves right now * - * @param $name + * TODO: Not sure if this covers all cases. + * + * @param string $name + * @param ?array $whiteList + * * @return bool */ - public static function isValidMacroName($name) + public static function isValidMacroName(string $name, ?array $whiteList = null): bool { - return preg_match('/^[A-z_][A-z_.\d]+$/', $name) + $hasMacroPattern = preg_match('/^[A-z_][A-z_.\d]+$/', $name) && ! preg_match('/\.$/', $name); + + if ($whiteList === null) { + return $hasMacroPattern; + } + + if (in_array($name, $whiteList, true)) { + return true; + } + + foreach ($whiteList as $pattern) { + if (str_contains($pattern, '*')) { + if ( + preg_match( + '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/', + $name + ) + ) { + return true; + } + } + } + + return false; } public static function renderStringWithVariables($string, ?array $whiteList = null) @@ -402,16 +434,15 @@ public static function renderStringWithVariables($string, ?array $whiteList = nu } else { // We got a macro $macroName = substr($string, $start + 1, $i - $start - 1); - if (static::isValidMacroName($macroName)) { - if ($whiteList === null || in_array($macroName, $whiteList)) { - if ($start > $offset) { - $parts[] = static::renderString( - substr($string, $offset, $start - $offset) - ); - } - $parts[] = $macroName; - $offset = $i + 1; + if (static::isValidMacroName($macroName, $whiteList)) { + if ($start > $offset) { + $parts[] = static::renderString( + substr($string, $offset, $start - $offset) + ); } + + $parts[] = $macroName; + $offset = $i + 1; } $start = false; diff --git a/library/Director/Objects/DirectorDatafield.php b/library/Director/Objects/DirectorDatafield.php index 65781d33a..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..110490b83 --- /dev/null +++ b/library/Director/Objects/DirectorProperty.php @@ -0,0 +1,430 @@ + null, + 'key_name' => null, + 'parent_uuid' => null, + 'category_id' => null, + 'value_type' => null, + 'label' => null, + 'description' => null + ]; + + protected $binaryProperties = [ + 'uuid', + 'parent_uuid' + ]; + + protected $relations = [ + 'category' => 'DirectorDatafieldCategory' + ]; + + /** @var DirectorProperty[] */ + private $items = []; + + /** @var ?DirectorDatalist */ + private $datalist = null; + + /** @var ?DirectorDatafieldCategory */ + private $category; + + protected function setDbProperties($properties) + { + unset($properties->parent_uuid_v); // hack to ignore virtual column, need a better solution + + return parent::setDbProperties($properties); + } + + public function setProperties($props) + { + unset($props['parent_uuid_v']); + + return parent::setProperties($props); + } + + /** + * Get category to which the property belongs to + * + * @return ?DirectorDatafieldCategory + * + * @throws NotFoundError + */ + public function getCategory(): ?DirectorDatafieldCategory + { + if ($this->category) { + return $this->category; + } + + if ($id = $this->get('category_id')) { + $this->category = DirectorDatafieldCategory::loadWithAutoIncId($id, $this->getConnection()); + + return $this->category; + } + + return null; + } + + /** + * Get the category name to which the property belongs to + * + * @return ?string + */ + public function getCategoryName(): ?string + { + $category = $this->getCategory(); + if ($category === null) { + return null; + } + + return $category->get('category_name'); + } + + /** + * Set the category to which the property belongs to + * + * @param DirectorDatafieldCategory|string|null $category + * + * @return void + */ + public function setCategory($category): void + { + if ($category === null) { + $this->category = null; + $this->set('category_id', null); + } elseif ($category instanceof DirectorDatafieldCategory) { + if ($category->hasBeenLoadedFromDb()) { + $this->set('category_id', $category->get('id')); + } + + $this->category = $category; + } else { + $category = DirectorDatafieldCategory::loadOptional($category, $this->getConnection()); + if ($category) { + $this->setCategory($category); + } else { + $this->setCategory(DirectorDatafieldCategory::create( + ['category_name' => $category], + $this->getConnection() + )); + } + } + } + + /** + * @throws NotFoundError + */ + public function export(): stdClass + { + $plain = (object) $this->getProperties(); + $db = $this->getDb(); + $uuid = Db\DbUtil::binaryResult($this->get('uuid')); + if ($uuid) { + $uuid = Uuid::fromBytes($uuid); + $plain->uuid = $uuid->toString(); + $plain->items = $this->exportChildren(); + + if (str_starts_with($plain->value_type, 'datalist-')) { + $query = $this->db->select()->from(['dd' => 'director_datalist'], ['list_name']) + ->join(['dpdl' => 'director_property_datalist'], 'dpdl.list_uuid = dd.uuid', []) + ->where($this->db->quoteInto( + 'dpdl.property_uuid = ?', + Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db) + )); + $plain->datalist = $this->db->fetchOne($query); + } + } + + if ($plain->parent_uuid !== null) { + $plain->parent_uuid = Uuid::fromBytes( + Db\DbUtil::binaryResult($plain->parent_uuid) + )->toString(); + } + + if (property_exists($plain, 'category_id')) { + $plain->category = $this->getCategoryName(); + unset($plain->category_id); + } + + return $plain; + } + + /** + * Export the child properties of this director property. + * + * @return array + */ + private function exportChildren(): array + { + $properties = []; + foreach ($this->fetchItemsFromDb() as $property) { + $properties[$property->get('key_name')] = $property->export(); + } + + return $properties; + } + + /** + * Get the child properties of this director property. + * + * @return DirectorProperty[] + */ + public function fetchItemsFromDb(): array + { + if ($this->items) { + return $this->items; + } + + $uuid = $this->get('uuid'); + if ($uuid === null) { + return []; + } + + $uuid = Uuid::fromBytes($uuid); + $query = $this->db->select() + ->from('director_property') + ->where( + 'parent_uuid = ?', + Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $this->db) + ); + + foreach (DirectorProperty::loadAll($this->connection, $query) as $item) { + foreach ($item->fetchItemsFromDb() as $nestedItem) { + $item->items[] = $nestedItem; + } + + $this->items[] = $item; + } + + return $this->items; + } + + public function getDatalist(): ?DirectorDatalist + { + if ($this->datalist) { + return $this->datalist; + } + + if (str_starts_with($this->get('value_type'), 'datalist-')) { + $query = $this->db->select()->from(['dd' => 'director_datalist'], ['list_name']) + ->join(['dpdl' => 'director_property_datalist'], 'dpdl.list_uuid = dd.uuid', []) + ->where($this->db->quoteInto( + 'dpdl.property_uuid = ?', + Db\DbUtil::quoteBinaryCompat($this->get('uuid'), $this->db) + )); + $this->datalist = DirectorDatalist::load($this->db->fetchOne($query), $this->connection); + } + + return $this->datalist; + } + + public static function fromDbRow($row, Db $connection) + { + $obj = static::create((array) $row, $connection); + $obj->loadedFromDb = true; + $obj->hasBeenModified = false; + $obj->modifiedProperties = []; + $obj->onLoadFromDb(); + + return $obj; + } + + + /** + * @throws NotFoundError + */ + public static function import(stdClass $plain, Db $db): static + { + $dba = $db->getDbAdapter(); + $uuid = $plain->uuid ?? null; + $datalist = null; + // DirectorProperty items (children) + $items = $plain->items ?? []; + unset($plain->items); + + // If DirectorProperty has a UUID, load it from the database using the "uuid" property + if ($uuid) { + $uuid = Uuid::fromString($uuid); + if (isset($plain->datalist)) { + $datalist = DirectorDatalist::loadOptional($plain->datalist, $db); + if (! $datalist && is_string($plain->datalist)) { + $datalist = DirectorDatalist::create(['list_name' => $plain->datalist], $db); + } + + unset($plain->datalist); + } + + $candidate = DirectorProperty::loadWithUniqueId($uuid, $db); + if ($candidate) { + assert($candidate instanceof DirectorProperty); + $candidate->setProperties((array) $plain); + $candidate->items = $candidate->importItems((array) $items, $db); + + return $candidate; + } + } + + // If DirectorProperty has no UUID (mainly for property children), + // load it from the database using the "key_name" property + $query = $dba->select()->from('director_property')->where('key_name = ?', $plain->key_name); + if (isset($plain->parent_uuid)) { + $query->where('parent_uuid = ?', Db\DbUtil::quoteBinaryCompat($plain->parent_uuid, $db->getDbAdapter())); + } else { + $query->where('parent_uuid is NULL'); + } + + $dbRow = $dba->fetchRow($query); + if ($dbRow !== false) { + $candidate = DirectorProperty::fromDbRow($dbRow, $db); + $export = $candidate->export(); + if (isset($export->parent_uuid)) { + $export->parent = DirectorProperty::loadWithUniqueId(Uuid::fromString($export->parent_uuid), $db) + ->get('key_name'); + unset($export->parent_uuid); + } + + CompareBasketObject::normalize($export); + $plainParentUuid = $plain->parent_uuid ?? null; + if (isset($plain->parent_uuid)) { + $parent = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($plain->parent_uuid), $db); + if ($parent === null) { + unset($plain->parent); + $plain->parent_uuid = $plainParentUuid; + } else { + $plain->parent = $parent->get('key_name'); + unset($plain->parent_uuid); + } + } + + unset($export->uuid); + if (CompareBasketObject::equals($export, $plain)) { + return $candidate; + } + + if ($plainParentUuid !== null) { + unset($plain->parent); + $plain->parent_uuid = $plainParentUuid; + } + } + + $property = static::create((array) $plain, $db); + + if ($datalist) { + $property->datalist = $datalist; + } + + if ($items) { + $property->items = $property->importItems((array) $items, $db); + } + + return $property; + } + + protected function onStore(): void + { + $db = $this->db; + $propertyUuid = Db\DbUtil::quoteBinaryCompat($this->get('uuid'), $db); + $db->delete( + 'director_property_datalist', + $db->quoteInto('property_uuid = ?', $propertyUuid) + ); + + if ($this->getDatalist()) { + $db->insert( + 'director_property_datalist', + [ + 'property_uuid' => $propertyUuid, + 'list_uuid' => Db\DbUtil::quoteBinaryCompat($this->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 = DirectorDatalist::loadOptional($value->datalist, $db); + if (! $datalist && is_string($value->datalist)) { + $datalist = DirectorDatalist::create(['list_name' => $value->datalist], $db); + } + + unset($value->datalist); + } + + $itemCandidate->setProperties((array) $value); + + if ($datalist) { + $itemCandidate->datalist = $datalist; + } + + if ($nestedItems) { + $itemCandidate->items = $this->importItems($nestedItems, $db); + } + + $itemCandidates[$key] = $itemCandidate; + } + + return $itemCandidates; + } +} diff --git a/library/Director/Objects/IcingaCommand.php b/library/Director/Objects/IcingaCommand.php index b6ded87fd..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..deee963c7 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -14,6 +14,7 @@ use Icinga\Module\Director\Objects\Extension\FlappingSupport; use Icinga\Module\Director\Resolver\HostServiceBlacklist; use InvalidArgumentException; +use PDO; use RuntimeException; class IcingaService extends IcingaObject implements ExportInterface @@ -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,13 +354,22 @@ 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 = in_array($propertyType, ['fixed-dictionary', 'dynamic-dictionary'], true); + $varName = '"' . $name . '"'; if (c::stringHasMacro($name)) { $extraName = c::renderKeyValue('name', c::renderStringWithVariables($name)); @@ -363,13 +378,25 @@ protected function renderObjectHeader() $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.overridenVar = %s\n", $varName) + : ''; + return sprintf( - "%s %s%s for (config in %s) {\n", + $header, $this->getObjectTypeName(), $this->getType(), $name, - $this->get('apply_for') - ) . $extraName; + $applyFor + ) + . $extraName + . $extraInfo; } return parent::renderObjectHeader(); @@ -387,6 +414,19 @@ protected function getLegacyObjectKeyName() } } + protected function fetchApplyForPropertyType(string $applyFor): ?string + { + $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.key_name = ?', $applyFor); + + $result = $this->db->fetchOne($query); + + return $result === false ? null : $result; + } + protected function rendersConditionalTemplate(): bool { return $this->getRenderingZone() === self::ALL_NON_GLOBAL_ZONES; @@ -624,6 +664,81 @@ 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) { + $query = $this->db + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type' + ] + ) + ->join(['parent_dp' => 'director_property'], 'dp.parent_uuid = parent_dp.uuid', []) + ->where("parent_dp.value_type = 'dynamic-dictionary'") + ->where("parent_dp.key_name = ?", $applyFor); + + $result = $this->db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + + $whiteList = ['value', 'host.*', 'value[*]', 'value[*].*']; + foreach ($result as $row) { + if (str_contains($row['key_name'], ' ')) { + continue; + } + + $variable = sprintf('value.%s', $row['key_name']); + if ($row['value_type'] === 'dynamic-dictionary') { + foreach ($this->fetchItemsForDictionary($row['uuid']) as $value) { + if (str_contains($value['key_name'], ' ')) { + continue; + } + + $whiteList[] = sprintf('%s.%s', $variable, $value['key_name']); + } + } + + $whiteList[] = $variable; + } + + $this->applyForWhiteList = $whiteList; + } + + $vars->setWhiteList($this->applyForWhiteList); + + return $vars; + } + + protected function fetchItemsForDictionary(string $uuid): array + { + $query = $this->db + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + ] + ) + ->join(['parent_dp' => 'director_property'], 'dp.parent_uuid = parent_dp.uuid', []) + ->where("dp.parent_uuid = ?", $uuid); + + return $this->db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + } + /** * TODO: Duplicate code, clean this up, split it into multiple methods diff --git a/library/Director/Objects/IcingaUser.php b/library/Director/Objects/IcingaUser.php index 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..75d969833 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -7,9 +7,11 @@ use Icinga\Exception\NotFoundError; use Icinga\Module\Director\CustomVariable\CustomVariableArray; use Icinga\Module\Director\Daemon\Logger; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; use Icinga\Module\Director\Db; use Icinga\Module\Director\Db\AppliedServiceSetLoader; use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Objects\IcingaService; use Icinga\Module\Director\Objects\IcingaTemplateResolver; use Icinga\Module\Director\Web\Form\IcingaObjectFieldLoader; @@ -24,6 +26,7 @@ use ipl\Html\Text; use ipl\Html\ValidHtml; use ipl\Orm\Model; +use PDO; use Throwable; class CustomVarRenderer extends CustomVarRendererHook @@ -31,12 +34,18 @@ class CustomVarRenderer extends CustomVarRendererHook /** @var array Related datafield configuration */ protected $fieldConfig = []; + /** @var array Related custom 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 = []; + protected $dictionaryLevel = 0; /** @var HtmlElement Table for dictionary fields */ @@ -194,10 +203,6 @@ public function prefetchForObject(Model $object): bool $fields = (new IcingaObjectFieldLoader($directorServiceObj))->getFields(); } - if (empty($fields)) { - return false; - } - $fieldsWithDataLists = []; foreach ($fields as $field) { $this->fieldConfig[$field->get('varname')] = [ @@ -241,6 +246,85 @@ public function prefetchForObject(Model $object): bool } } + if ($service === null) { + $customProperties = $this->getObjectCustomProperties($directorHostObj); + } else { + $customProperties = $this->getObjectCustomProperties($directorServiceObj); + } + + if (empty($customProperties)) { + return true; + } + + $customPropertiesWithDatalists = []; + foreach ($customProperties as $customProperty) { + $propertyName = $customProperty['key_name']; + $this->customVariableConfig[$propertyName] = ['label' => $customProperty['label']]; + if (isset($customProperty['category'])) { + $this->customVariableConfig[$propertyName]['group'] = $customProperty['category']; + } + + if (str_starts_with($customProperty['value_type'], 'datalist-')) { + $customPropertiesWithDatalists[$customProperty['uuid']] = $customProperty; + } elseif (str_ends_with($customProperty['value_type'], '-dictionary')) { + $this->dictionaryNames[] = $customProperty['key_name']; + } + } + + $dictionaryItems = $db->select()->from( + ['dpp' => 'director_property'], + [] + ) + ->join(['dpc' => 'director_property'], 'dpp.uuid = dpc.parent_uuid', []) + ->columns([ + 'parent_name' => 'dpp.key_name', + 'key_name' => 'dpc.key_name', + 'label' => 'dpc.label', + 'value_type' => 'dpc.value_type', + 'uuid' => 'dpc.uuid' + ])->where('dpp.value_type', '*-dictionary'); + + foreach ($dictionaryItems as $dictionaryItem) { + $propertyName = $dictionaryItem->key_name; + + $this->customPropertyDictionaries[$dictionaryItem->parent_name][$propertyName] + = $dictionaryItem->label; + if (is_string($propertyName)) { + $this->customVariableConfig[$propertyName] = ['label' => $dictionaryItem->label]; + } + + if (str_starts_with($dictionaryItem->value_type, 'datalist-')) { + $customPropertiesWithDatalists[$dictionaryItem->uuid] = $dictionaryItem; + } + } + + $dataListEntries = $db->select()->from( + ['dpd' => 'director_property_datalist'], + [ + 'property_uuid' => 'dpd.property_uuid', + 'entry_name' => 'dde.entry_name', + 'entry_value' => 'dde.entry_value', + 'property_name' => 'dpc.key_name', + ] + )->join( + ['dd' => 'director_datalist'], + 'dd.uuid = dpd.list_uuid', + [] + )->join( + ['dde' => 'director_datalist_entry'], + 'dd.id = dde.list_id', + [] + )->join( + ['dpc' => 'director_property'], + 'dpd.property_uuid = dpc.uuid', + [] + ); + + foreach ($dataListEntries as $dataListEntry) { + $this->datalistMaps[$dataListEntry->property_name][$dataListEntry->entry_name] + = $dataListEntry->entry_value; + } + return true; } catch (Throwable $e) { Logger::error("%s\n%s", $e, $e->getTraceAsString()); @@ -249,16 +333,79 @@ public function prefetchForObject(Model $object): bool } } + /** + * Get custom properties for the host. + * + * @return array + */ + protected function getObjectCustomProperties(IcingaObject $object, bool $isOverrideVars = false): array + { + if ($object->uuid === null) { + return []; + } + + $type = $object->getShortTableName(); + $parents = $object->listAncestorIds(); + + $uuids = []; + $db = $this->db(); + + $objectClass = DbObjectTypeRegistry::classByType($type); + foreach ($parents as $parent) { + $uuids[] = $objectClass::loadWithAutoIncId($parent, $db)->get('uuid'); + } + + $uuids[] = $object->get('uuid'); + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'category' => 'cpc.category_name', + $type . '_uuid' => 'iop.' . $type . '_uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label', + 'children' => 'COUNT(cdp.uuid)' + ] + ) + ->join(['iop' => "icinga_$type" . '_property'], 'dp.uuid = iop.property_uuid', []) + ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) + ->joinLeft(['cpc' => 'director_datafield_category'], 'dp.category_id = cpc.id', []) + ->where('iop.' . $type . '_uuid IN (?)', $uuids) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->order( + "FIELD(dp.value_type, 'string', 'number', 'bool', 'datalist-strict', 'datalist-non-strict'," + . " 'dynamic-array', 'fixed-dictionary', 'dynamic-dictionary')" + ) + ->order('children') + ->order('key_name'); + + $result = []; + + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + $result[$row['key_name']] = $row; + } + + return $result; + } + public function renderCustomVarKey(string $key) { try { - if (isset($this->fieldConfig[$key]['label'])) { - return new HtmlElement( - 'span', - Attributes::create(['title' => $this->fieldConfig[$key]['label'] . " [$key]"]), - Text::create($this->fieldConfig[$key]['label']) - ); + $label = $this->fieldConfig[$key]['label'] + ?? $this->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()); } @@ -268,38 +415,40 @@ public function renderCustomVarKey(string $key) public function renderCustomVarValue(string $key, $value) { + if (! (isset($this->fieldConfig[$key]) || 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; - } + if (is_array($value) && ! isset($this->customPropertyDictionaries[$key])) { + $renderedValue = []; + foreach ($value as $k => $v) { + if (is_string($v) && isset($this->datalistMaps[$key][$v])) { + $renderedValue[$k] = new HtmlElement( + 'span', + Attributes::create(['title' => $this->datalistMaps[$key][$v] . " [$v]"]), + Text::create($this->datalistMaps[$key][$v]) + ); + } else { + $renderedValue[$k] = $v; } - - return $renderedValue; } - if (is_string($value) && isset($this->datalistMaps[$key][$value])) { - return new HtmlElement( - 'span', - Attributes::create(['title' => $this->datalistMaps[$key][$value] . " [$value]"]), - Text::create($this->datalistMaps[$key][$value]) - ); - } elseif ($value !== null && in_array($key, $this->dictionaryNames)) { - return $this->renderDictionaryVal($key, (array) $value); - } + return $renderedValue; + } + + if (is_string($value) && isset($this->datalistMaps[$key][$value])) { + return new HtmlElement( + 'span', + Attributes::create(['title' => $this->datalistMaps[$key][$value] . " [$value]"]), + Text::create($this->datalistMaps[$key][$value]) + ); + } elseif ($value !== null && in_array($key, $this->dictionaryNames)) { + return $this->renderDictionaryVal($key, (array) $value); } } catch (Throwable $e) { Logger::error("%s\n%s", $e, $e->getTraceAsString()); @@ -312,6 +461,8 @@ public function identifyCustomVarGroup(string $key): ?string { if (isset($this->fieldConfig[$key]['group'])) { return $this->fieldConfig[$key]['group']; + } elseif (isset($this->customVariableConfig[$key]['group'])) { + return $this->customVariableConfig[$key]['group']; } return null; @@ -354,8 +505,8 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml $this->dictionaryLevel++; - foreach ($value as $key => $val) { - if ($key !== null && is_array($val) || is_object($val)) { + foreach ($value as $k => $val) { + if ($k !== null && is_array($val) || is_object($val)) { $val = (array) $val; $numChildItems = count($val); @@ -363,7 +514,7 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml new HtmlElement( 'tr', Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), - new HtmlElement('th', null, Html::wantHtml($key)), + new HtmlElement('th', null, Html::wantHtml($k)), new HtmlElement( 'td', null, @@ -375,36 +526,39 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml $this->dictionaryLevel++; foreach ($val as $childKey => $childVal) { $childVal = $this->renderCustomVarValue($childKey, $childVal) ?? $childVal; - if (! in_array($childKey, $this->dictionaryNames)) { - $label = $this->renderCustomVarKey($childKey) ?? $childKey; - - if (is_array($childVal)) { - $this->renderArrayVal($label, $childVal); - } else { - $this->dictionaryBody->addHtml( - new HtmlElement( - 'tr', - Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), - new HtmlElement('th', null, Html::wantHtml( - $label - )), - new HtmlElement('td', null, Html::wantHtml($childVal)) - ) - ); - } + $label = $this->renderCustomVarKey($childKey) ?? $childKey; + if ( + ! in_array($childKey, $this->dictionaryNames) + && ! array_key_exists($childKey, $this->customPropertyDictionaries) + && is_array($childVal) + ) { + $this->renderArrayVal($label, $childVal); + } elseif (array_key_exists($childKey, $this->customPropertyDictionaries)) { + $this->renderArrayVal($label, $childVal, $childKey); + } else { + $this->dictionaryBody->addHtml( + new HtmlElement( + 'tr', + Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), + new HtmlElement('th', null, Html::wantHtml( + $label + )), + new HtmlElement('td', null, Html::wantHtml($childVal)) + ) + ); } } $this->dictionaryLevel--; } elseif (is_array($val)) { - $this->renderArrayVal($key, $val); + $this->renderArrayVal($key, $val, $key); } else { $this->dictionaryBody->addHtml( new HtmlElement( 'tr', Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), - new HtmlElement('th', null, Html::wantHtml($key)), - new HtmlElement('td', null, Html::wantHtml($val)) + new HtmlElement('th', null, $this->renderCustomVarKey($key) ?? Html::wantHtml($key)), + new HtmlElement('td', null, $this->renderCustomVarValue($key, $val) ?? Html::wantHtml($val)) ) ); } @@ -420,23 +574,29 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml } /** - * Render an array + * Render an array, if the passed key is a part of dictionary, then render as a dictionary * * @param HtmlElement|string $name * @param array $array * * @return void */ - protected function renderArrayVal($name, array $array) + protected function renderArrayVal($name, array $array, ?string $key = null): void { $numItems = count($array); + if ($key) { + $prefix = ' (Dictionary)'; + } else { + $prefix = ' (Array)'; + } + if ($name instanceof HtmlElement) { - $name->addHtml(Text::create(' (Array)')); + $name->addHtml(Text::create($prefix)); } else { $name = (new HtmlDocument())->addHtml( Html::wantHtml($name), - Text::create(' (Array)') + Text::create($prefix) ); } @@ -453,12 +613,24 @@ protected function renderArrayVal($name, array $array) ksort($array); foreach ($array as $key => $value) { + if (! $key instanceof HtmlElement) { + $renderedKey = $this->renderCustomVarKey($key) ?? Html::wantHtml("[$key]"); + } else { + $renderedKey = Html::wantHtml("[$key]"); + } + + if (! $value instanceof HtmlElement) { + $renderedValue = $this->renderCustomVarValue($key, $value) ?? Html::wantHtml($value); + } else { + $renderedValue = Html::wantHtml($value); + } + $this->dictionaryBody->addHtml( new HtmlElement( 'tr', Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), - new HtmlElement('th', null, Html::wantHtml("[$key]")), - new HtmlElement('td', null, Html::wantHtml($value)) + new HtmlElement('th', null, $renderedKey), + new HtmlElement('td', null, $renderedValue) ) ); } diff --git a/library/Director/Resolver/TemplateTree.php b/library/Director/Resolver/TemplateTree.php index 5d5d19eb7..8a5645eb1 100644 --- a/library/Director/Resolver/TemplateTree.php +++ b/library/Director/Resolver/TemplateTree.php @@ -17,6 +17,8 @@ class TemplateTree protected $parents; + protected $parentsUuids; + protected $children; protected $rootNodes; @@ -388,6 +390,8 @@ protected function prepareTree() $rootNodes = []; $children = []; $names = []; + $parentsUuids = []; + $uuids = []; foreach ($templates as $row) { $id = (int) $row->id; $pid = (int) $row->parent_id; @@ -406,6 +410,7 @@ protected function prepareTree() $names[$pid] = $row->parent_name; $parents[$id][$pid] = $row->parent_name; + $parentsUuids[$id][$pid] = $row->parent_uuid; if (! array_key_exists($pid, $children)) { $children[$pid] = []; @@ -414,7 +419,8 @@ protected function prepareTree() $children[$pid][$id] = $row->name; } - $this->parents = $parents; + $this->parents = $parents; + $this->parentsUuids = $parentsUuids; $this->children = $children; $this->rootNodes = $rootNodes; $this->names = $names; @@ -460,6 +466,7 @@ public function fetchTemplates() 'object_type' => 'o.object_type', 'parent_id' => 'p.id', 'parent_name' => 'p.object_name', + 'parent_uuid' => 'p.uuid' ] )->joinLeft( ['i' => $table . '_inheritance'], diff --git a/library/Director/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..a372be598 100644 --- a/library/Director/Web/Table/IcingaHostAppliedServicesTable.php +++ b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php @@ -30,6 +30,8 @@ class IcingaHostAppliedServicesTable extends SimpleQueryBasedTable private $allApplyRules; + private $useDeprecatedLink = false; + /** * @param IcingaHost $host * @return static @@ -103,12 +105,18 @@ public function renderRow($row) $applyFor = sprintf('(apply for %s) ', $row->apply_for); } + $url = 'director/host/appliedservice'; + + if ($this->useDeprecatedLink) { + $url .= 'deprecated'; + } + $link = Link::create(sprintf( $this->translate('%s %s(%s)'), $row->name, $applyFor, $this->renderApplyFilter($row->filter) - ), 'director/host/appliedservice', [ + ), $url, [ 'name' => $this->host->getObjectName(), 'service_id' => $row->id, ]); @@ -117,6 +125,13 @@ public function renderRow($row) return $this::row([$link], $attributes); } + public function useDeprecatedLink(bool $useDeprecatedLink = true): self + { + $this->useDeprecatedLink = $useDeprecatedLink; + + return $this; + } + /** * @param Filter $assignFilter * diff --git a/library/Director/Web/Table/IcingaServiceSetServiceTable.php b/library/Director/Web/Table/IcingaServiceSetServiceTable.php index 1ea8a28be..6b704f73b 100644 --- a/library/Director/Web/Table/IcingaServiceSetServiceTable.php +++ b/library/Director/Web/Table/IcingaServiceSetServiceTable.php @@ -39,6 +39,8 @@ class IcingaServiceSetServiceTable extends ZfQueryBasedTable /** @var string|null */ protected $highlightedService; + private $useDeprecatedLink = false; + /** * @param IcingaServiceSet $set * @return static @@ -101,6 +103,13 @@ public function highlightService($service) return $this; } + public function useDeprecatedLink(bool $useDeprecatedLink = true): self + { + $this->useDeprecatedLink = $useDeprecatedLink; + + return $this; + } + /** * @param $row * @return BaseHtmlElement @@ -122,6 +131,9 @@ protected function getServiceLink($row) 'set' => $row->service_set ]; $url = 'director/host/servicesetservice'; + if ($this->useDeprecatedLink) { + $url .= 'deprecated'; + } } else { if (is_resource($row->uuid)) { $row->uuid = stream_get_contents($row->uuid); diff --git a/library/Director/Web/Table/ObjectsTableService.php b/library/Director/Web/Table/ObjectsTableService.php index d7d7462f4..60a268375 100644 --- a/library/Director/Web/Table/ObjectsTableService.php +++ b/library/Director/Web/Table/ObjectsTableService.php @@ -20,6 +20,8 @@ class ObjectsTableService extends ObjectsTable protected $title; + private $useDeprecatedLink = false; + /** @var IcingaHost */ protected $inheritedBy; @@ -61,6 +63,13 @@ public function setTitle($title) return $this; } + public function useDeprecatedLink(bool $useDeprecatedLink = true): self + { + $this->useDeprecatedLink = $useDeprecatedLink; + + return $this; + } + public function setHost(IcingaHost $host) { $this->host = $host; @@ -150,9 +159,14 @@ protected function getInheritedServiceLink($row, $target) 'inheritedFrom' => $row->host, ]; + $url = 'director/host/inheritedservice'; + if ($this->useDeprecatedLink) { + $url .= 'deprecated'; + } + return Link::create( $row->object_name, - 'director/host/inheritedservice', + $url, $params ); } diff --git a/library/Director/Web/Widget/CustomVarFieldsTable.php b/library/Director/Web/Widget/CustomVarFieldsTable.php new file mode 100644 index 000000000..cb3b42660 --- /dev/null +++ b/library/Director/Web/Widget/CustomVarFieldsTable.php @@ -0,0 +1,61 @@ + '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([HtmlElement::create('p', null, $property->label)])->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->value_type)]), + ]; + + if (isset($property->used_count) && $property->used_count > 0) { + $columns[] = static::td([HtmlElement::create('p', null, $this->translate('In use'))]); + } else { + $columns[] = static::td([HtmlElement::create('p', null, $this->translate('Not in use'))]); + } + + $this->addHtml(static::tr($columns)); + } + } +} diff --git a/library/Director/Web/Widget/CustomVarObjectList.php b/library/Director/Web/Widget/CustomVarObjectList.php new file mode 100644 index 000000000..6f3f865a6 --- /dev/null +++ b/library/Director/Web/Widget/CustomVarObjectList.php @@ -0,0 +1,69 @@ +setItemLayoutClass(MinimalItemLayout::class); + } + + public function getDetailActionsDisabled(): bool + { + return $this->actionDisabled; + } + + public function setDetailActionsDisabled(bool $actionDisabled = true): static + { + $this->actionDisabled = $actionDisabled; + + return $this; + } + + protected function createListItem(object $data): ListItem + { + $item = parent::createListItem($data); + if ($this->getDetailActionsDisabled()) { + return $item; + } + + $objectInstance = $data->object_class; + if ($data->object_class === 'service' && $data->host_name !== null) { + $filter = Filter::all( + Filter::equal('name', $data->name), + Filter::equal('host_name', $data->host_name) + ); + } else { + $filter = Filter::equal('name', $data->name); + } + + $url = Url::fromPath("director/$objectInstance/variables"); + $this->getAttributes()->add('class', 'action-list'); + $this->getAttributes() + ->registerAttributeCallback('data-icinga-detail-url', function () use ($url) { + return $this->getDetailActionsDisabled() ? null : (string) $url; + }); + + $item->getAttributes() + ->registerAttributeCallback('data-action-item', function () { + return ! $this->getDetailActionsDisabled(); + }) + ->registerAttributeCallback('data-icinga-detail-filter', function () use ($filter) { + return $this->getDetailActionsDisabled() ? null : QueryString::render($filter); + }); + + return $item; + } +} diff --git a/library/Director/Web/Widget/CustomVarRenderer.php b/library/Director/Web/Widget/CustomVarRenderer.php new file mode 100644 index 000000000..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; + } +} From 9e24d146351d318973f81aab48371d56fb082b46 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 29 May 2026 14:22:16 +0200 Subject: [PATCH 003/221] Add forms, controllers, and REST API handler for custom variable management Introduce the full UI layer for creating, editing, and assigning custom variables (DirectorProperties) to Icinga objects: - CustomvarController: CRUD for global custom variable definitions - VariablesController: per-object variable assignment - HostController: host-specific dictionary member management - SuggestionsController: datalist suggestions with PostgreSQL support - CustomVariableForm / CustomVariablesForm / DeleteCustomVariableForm: forms for managing variables on objects with multipart update support - DictionaryElements (Dictionary, DictionaryItem, NestedDictionary, NestedDictionaryItem): composable form elements for structured types - ArrayElement, IplBoolean: new reusable form elements - DatalistEntryValidator: validates datalist-constrained variable values - ObjectController: fetchNestedDictionaryKeys, multipart reload handling - IcingaObjectHandler (REST API): expose and accept structured custom variables in PUT/POST requests; support PostgreSQL UUID binaries - ObjectTabs: add Variables tab to all object types - CSS / JS: styles for item lists, action lists, custom variable forms, and host-service deactivation; JS fix for multipart form reloads - configuration.php: register new routes and the Custom Variables dashlet --- .../controllers/CustomvarController.php | 448 ++++++++++- application/controllers/DataController.php | 8 +- application/controllers/HostController.php | 243 +++++- .../controllers/SuggestionsController.php | 69 ++ .../controllers/VariablesController.php | 83 ++ application/forms/CustomVariableForm.php | 631 +++++++++++++++ application/forms/CustomVariablesForm.php | 413 ++++++++++ .../forms/DeleteCustomVariableForm.php | 507 ++++++++++++ .../forms/DictionaryElements/Dictionary.php | 255 ++++++ .../DictionaryElements/DictionaryItem.php | 489 ++++++++++++ .../DictionaryElements/NestedDictionary.php | 222 ++++++ .../NestedDictionaryItem.php | 136 ++++ .../forms/HostServiceBlacklistForm.php | 140 ++++ application/forms/IcingaMultiEditForm.php | 6 - application/forms/IcingaServiceForm.php | 57 +- application/forms/ObjectCustomvarForm.php | 114 +++ .../Validator/DatalistEntryValidator.php | 62 ++ configuration.php | 12 + .../Dashlet/CustomVariablesDashlet.php | 33 + .../Dashboard/Dashlet/DatafieldDashlet.php | 2 +- library/Director/Dashboard/DataDashboard.php | 3 +- .../Data/CustomVariableReferenceLoader.php | 3 +- .../Director/RestApi/IcingaObjectHandler.php | 226 +++++- .../Web/Controller/ObjectController.php | 750 +++++++++++++++++- .../Web/Form/Element/ArrayElement.php | 120 +++ .../Director/Web/Form/Element/IplBoolean.php | 66 ++ .../Web/Form/IcingaObjectFieldLoader.php | 10 - .../Table/IcingaHostAppliedServicesTable.php | 1 + .../Table/IcingaServiceSetServiceTable.php | 8 + .../Web/Table/ObjectsTableService.php | 8 + library/Director/Web/Tabs/ObjectTabs.php | 21 +- public/css/action-list.less | 14 + public/css/custom-var-fields-table.less | 3 + public/css/custom-variable-form.less | 5 + public/css/custom-variables-form.less | 167 ++++ public/css/host-service-deactivate-form.less | 12 + public/css/item-list.less | 73 ++ public/css/item/item-layout.less | 6 + public/css/module.less | 64 +- public/js/module.js | 12 +- 40 files changed, 5388 insertions(+), 114 deletions(-) create mode 100644 application/controllers/SuggestionsController.php create mode 100644 application/controllers/VariablesController.php create mode 100644 application/forms/CustomVariableForm.php create mode 100644 application/forms/CustomVariablesForm.php create mode 100644 application/forms/DeleteCustomVariableForm.php create mode 100644 application/forms/DictionaryElements/Dictionary.php create mode 100644 application/forms/DictionaryElements/DictionaryItem.php create mode 100644 application/forms/DictionaryElements/NestedDictionary.php create mode 100644 application/forms/DictionaryElements/NestedDictionaryItem.php create mode 100644 application/forms/HostServiceBlacklistForm.php create mode 100644 application/forms/ObjectCustomvarForm.php create mode 100644 application/forms/Validator/DatalistEntryValidator.php create mode 100644 library/Director/Dashboard/Dashlet/CustomVariablesDashlet.php create mode 100644 library/Director/Web/Form/Element/ArrayElement.php create mode 100644 library/Director/Web/Form/Element/IplBoolean.php create mode 100644 public/css/action-list.less create mode 100644 public/css/custom-var-fields-table.less create mode 100644 public/css/custom-variable-form.less create mode 100644 public/css/custom-variables-form.less create mode 100644 public/css/host-service-deactivate-form.less create mode 100644 public/css/item-list.less create mode 100644 public/css/item/item-layout.less diff --git a/application/controllers/CustomvarController.php b/application/controllers/CustomvarController.php index f0d4574c0..3798a2afb 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -2,16 +2,454 @@ 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(); + + $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) { + $parentUuid = Uuid::fromString($parentUuid); + $parent = $this->fetchProperty($parentUuid); + + if ($parent['parent_uuid'] !== null) { + $usedCount = $this->fetchPropertyUsedCount(Uuid::fromBytes($parent['parent_uuid'])); + } else { + $usedCount = $this->fetchPropertyUsedCount($parentUuid); + } + } else { + $usedCount = $this->fetchPropertyUsedCount($uuid); + } + + $property['used_count'] = $usedCount; + + if ($property['value_type'] === 'dynamic-array' || str_starts_with($property['value_type'], 'datalist-')) { + $itemTypeQuery = $db + ->select()->from('director_property', 'value_type') + ->where( + 'parent_uuid = ? AND key_name = \'0\'', + Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db) + ); + + $property['item_type'] = $db->fetchOne($itemTypeQuery); + } + + if (str_starts_with($property['value_type'], 'datalist-')) { + $datalistId = $db + ->select()->from(['dl' => 'director_datalist'], 'id') + ->join(['dpl' => 'director_property_datalist'], 'dpl.list_uuid = dl.uuid', []) + ->where( + 'dpl.property_uuid = ?', + Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db) + ); + + $property['list'] = $db->fetchOne($datalistId); + } + + $showFields = $this->showFields($property['value_type']); + $propertyForm = (new CustomVariableForm($this->db, $uuid, $parentUuid !== null, $parentUuid)) + ->populate($property) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(CustomVariableForm::ON_SENT, function (CustomVariableForm $form) use ($property, &$showFields) { + $showFields = $showFields && $form->getValue('value_type') === $property['value_type']; + }) + ->on(CustomVariableForm::ON_SUBMIT, function (CustomVariableForm $form) { + Notification::success(sprintf( + $this->translate('Custom variable configuration "%s" has successfully been saved'), + $form->getValue('key_name') + )); + + $this->sendExtraUpdates(['#col1']); + $redirectUrl = Url::fromPath( + 'director/customvar', + ['uuid' => $form->getUUid()->toString()] + ); + + if ($form->getParentUUid()) { + $redirectUrl->addParams(['parent_uuid' => $form->getParentUUid()->toString()]); + } + + $this->redirectNow($redirectUrl); + }); + + if ($parent) { + $propertyForm + ->setHideKeyNameElement($parent['value_type'] === 'fixed-array') + ->setIsNestedField($parent['parent_uuid'] !== null); + } + + $propertyForm->handleRequest($this->getServerRequest()); + $this->addContent($propertyForm); + + if ($showFields) { + $this->addContent(new HtmlElement('h2', null, Text::create($this->translate('Fields')))); + $button = (new ButtonLink( + Text::create($this->translate('Create Field')), + Url::fromPath('director/customvar/add-field', [ + 'uuid' => $uuid->toString() + ]), + null, + ['class' => 'control-button'] + ))->openInModal(); + + $fieldQuery = $db + ->select() + ->from(['dp' => 'director_property'], []) + ->joinLeft(['ihp' => 'icinga_host_property'], 'ihp.property_uuid = dp.parent_uuid', []) + ->columns([ + 'uuid', + 'parent_uuid', + 'key_name', + 'category_id', + 'value_type', + 'label', + 'description', + 'used_count' => $property['used_count'] > 0 ? 'COUNT(1)' : '0', + ]) + ->where('parent_uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)) + ->group('dp.uuid') + ->order('key_name'); + + $this->addContent($button); + + $fields = $db->fetchAll($fieldQuery); + + if (empty($fields)) { + $this->addContent( + new EmptyStateBar( + $this->translate('No fields have been added yet') + ) + ); + } else { + $this->addContent(new CustomVarFieldsTable($fields, true)); + } + } + + if ($parentUuid) { + $keyName = $parent['value_type'] === 'fixed-array' + ? $property['label'] + : $property['key_name']; + + $title = $this->translate('Edit Field') . ': ' . $keyName; + } else { + $title = $this->translate('Custom Variable') . ': ' . $property['key_name']; + } + + $this->setTitle($title); + $this->setTitleTab('customvar'); + $this->setAutorefreshInterval(10); + } + + public function usageAction(): void + { + $objectClass = null; + $usageList = (new CustomVarObjectList($this->fetchCustomVarUsage())) + ->setDetailActionsDisabled(false) + ->on( + CustomVarObjectList::BEFORE_ITEM_ADD, + function (ListItem $item, $data) use (&$objectClass, &$usageList) { + if ($objectClass !== $data->object_class) { + $usageList->addHtml( + HtmlElement::create( + 'li', + ['class' => 'list-item'], + HtmlElement::create('h2', content: ucfirst($data->object_class) . 's') + ) + ); + $objectClass = $data->object_class; + } + } + ); + + $this->addContent($usageList); + + $this->setTitle($this->translate('Custom Variable Usage')); + $this->setTitleTab('usage'); + $this->setAutorefreshInterval(10); + } + + /** + * Fetch the give custom variable usage in templates + * + * @return array + */ + private function fetchCustomVarUsage(): array + { + $uuid = $this->uuid; + $property = $this->fetchProperty($uuid); + $db = $this->db->getDbAdapter(); + if (isset($property['parent_uuid'])) { + $parentUuid = Uuid::fromBytes($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')); + $uuid = Uuid::fromString($uuid); + + $parent = $this->fetchProperty($uuid); + $propertyForm = (new CustomVariableForm($this->db, null, true, $uuid)) + ->setHideKeyNameElement($parent['value_type'] === 'fixed-array') + ->setIsNestedField($parent['parent_uuid'] !== null) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(CustomVariableForm::ON_SUBMIT, function (CustomVariableForm $form) { + Notification::success(sprintf( + $this->translate('Custom variable configuration "%s" has successfully been saved'), + $form->getValue('key_name') + )); + + $this->sendExtraUpdates(['#col1']); + $this->redirectNow( + Url::fromPath('director/customvar', ['uuid' => $form->getParentUUid()->toString()]) + ); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($propertyForm); + } + + public function deleteAction(): void + { + $uuid = $this->uuid; + $property = $this->fetchProperty($uuid); + $parent = []; + if ($property['parent_uuid'] !== null) { + $parent = $this->fetchProperty(Uuid::fromBytes($property['parent_uuid'])); + } + + $form = (new DeleteCustomVariableForm($this->db, $property, $parent)) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(DeleteCustomVariableForm::ON_SUBMIT, function () { + Notification::success($this->translate('Custom variable configuration has been successfully deleted')); + $this->sendExtraUpdates(['#col1']); + $this->redirectNow('__CLOSE__'); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($form); + + $this->setTitle($this->translate('Delete Property') . ': ' . $property['key_name']); + } + public function variantsAction() { $varName = $this->params->getRequired('name'); - $this->addSingleTab($this->translate('Custom Variable')) - ->addTitle($this->translate('Custom Variable variants: %s'), $varName); - CustomvarVariantsTable::create($this->db(), $varName)->renderTo($this); + $title = sprintf($this->translate('Custom Variable variants: %s'), $varName); + $this->setTitle($title); + $this->addTitleTab($title); + $this->addContent(CustomvarVariantsTable::create($this->db, $varName)); + } + + /** + * Fetch property for the given UUID + * + * @param UuidInterface $uuid UUID of the given property + * + * @return array + */ + private function fetchProperty(UuidInterface $uuid): array + { + $db = $this->db->getDbAdapter(); + + $query = $db + ->select() + ->from(['dp' => 'director_property'], []) + ->joinLeft(['ihp' => 'icinga_host_property'], 'ihp.property_uuid = dp.uuid', []) + ->columns([ + 'key_name', + 'uuid', + 'parent_uuid', + 'category_id', + 'value_type', + 'label', + 'description' + ]) + ->where('uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + return Db\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/DataController.php b/application/controllers/DataController.php index 7480db244..035027f51 100644 --- a/application/controllers/DataController.php +++ b/application/controllers/DataController.php @@ -6,6 +6,7 @@ use Icinga\Exception\NotFoundError; use Icinga\Module\Director\Forms\DirectorDatalistEntryForm; use Icinga\Module\Director\Forms\DirectorDatalistForm; +use Icinga\Module\Director\Forms\IcingaHostDictionaryMemberForm; use Icinga\Module\Director\Forms\IcingaServiceDictionaryMemberForm; use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Objects\IcingaHost; @@ -193,7 +194,12 @@ public function dictionaryAction() 'object_name' => $field->getSetting('template_name') ], $connection); - $form = new IcingaServiceDictionaryMemberForm(); + if ($object instanceof IcingaHost) { + $form = new IcingaHostDictionaryMemberForm(); + } else { + $form = new IcingaServiceDictionaryMemberForm(); + } + $form->setDb($connection); if ($instance) { $instanceObject = $object::create([ diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php index 66588b840..ac802a1ff 100644 --- a/application/controllers/HostController.php +++ b/application/controllers/HostController.php @@ -4,6 +4,8 @@ use gipfl\Web\Widget\Hint; use Icinga\Module\Director\Auth\Permission; +use Icinga\Module\Director\Forms\HostServiceBlacklistForm; +use Icinga\Module\Director\Forms\IcingaServiceForm; use Icinga\Module\Director\Integration\Icingadb\IcingadbBackend; use Icinga\Module\Director\Integration\MonitoringModule\Monitoring; use Icinga\Module\Director\Web\Table\ObjectsTableService; @@ -12,11 +14,9 @@ use gipfl\IcingaWeb2\Url; use gipfl\IcingaWeb2\Widget\Tabs; use Exception; -use Icinga\Module\Director\CustomVariable\CustomVariableDictionary; use Icinga\Module\Director\Db\AppliedServiceSetLoader; use Icinga\Module\Director\DirectorObject\Lookup\ServiceFinder; use Icinga\Module\Director\Forms\IcingaAddServiceForm; -use Icinga\Module\Director\Forms\IcingaServiceForm; use Icinga\Module\Director\Forms\IcingaServiceSetForm; use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaService; @@ -25,7 +25,6 @@ use Icinga\Module\Director\Repository\IcingaTemplateRepository; use Icinga\Module\Director\Web\Controller\ObjectController; use Icinga\Module\Director\Web\SelfService; -use Icinga\Module\Director\Web\Table\IcingaHostAppliedForServiceTable; use Icinga\Module\Director\Web\Table\IcingaHostAppliedServicesTable; use Icinga\Module\Director\Web\Table\IcingaServiceSetServiceTable; @@ -250,11 +249,19 @@ public function servicesAction() ->removeQueryLimit(); if (count($table)) { + $deprecatedTable = clone $table; $content->add( $table->setTitle(sprintf( $this->translate('Inherited from %s'), $parent->getObjectName() - )) + )), + ); + + $content->add( + $deprecatedTable->setTitle(sprintf( + $this->translate('Inherited from %s (referencing deprecated Datafields)'), + $parent->getObjectName(), + ))->useDeprecatedLink() ); } } @@ -268,23 +275,36 @@ public function servicesAction() $appliedSets = AppliedServiceSetLoader::fetchForHost($host); foreach ($appliedSets as $set) { - $title = sprintf($this->translate('%s (Applied Service set)'), $set->getObjectName()); + $servicesetserviceTable = IcingaServiceSetServiceTable::load($set) + ->setBranch($branch) + ->setAffectedHost($host) + ->removeQueryLimit(); + + $deprecatedservicesetserviceTable = clone $servicesetserviceTable; + $content->add($servicesetserviceTable->setTitle(sprintf( + $this->translate('%s (Applied Service set)'), + $set->getObjectName() + ))); $content->add( - IcingaServiceSetServiceTable::load($set) - // ->setHost($host) - ->setBranch($branch) - ->setAffectedHost($host) - ->setTitle($title) - ->removeQueryLimit() + $deprecatedservicesetserviceTable + ->setTitle(sprintf($this->translate( + '%s (Applied Service set [referencing deprecated Datafields])' + ), $set->getObjectName())) + ->useDeprecatedLink() ); } - $table = IcingaHostAppliedServicesTable::load($host) - ->setTitle($this->translate('Applied services')); + $table = IcingaHostAppliedServicesTable::load($host); if (count($table)) { - $content->add($table); + $deprecatedTable = clone $table; + $content->add($table->setTitle($this->translate('Applied services'))); + $content->add( + $deprecatedTable + ->setTitle($this->translate('Applied services (referencing deprecated Datafields)')) + ->useDeprecatedLink() + ); } } @@ -406,12 +426,23 @@ protected function addHostServiceSetTables(IcingaHost $host, ?IcingaHost $affect ->setHost($host) ->setBranch($this->getBranch()) ->setAffectedHost($affectedHost) - ->removeQueryLimit() - ->setTitle($title); + ->removeQueryLimit(); + + $deprecatedTable = clone $table; + + $table->setTitle($title); + $deprecatedTable + ->useDeprecatedLink() + ->setTitle(sprintf( + $this->translate('%s (Service set [referencing deprecated Datafields])'), + $name + )); if ($roService) { $table->setReadonly()->highlightService($roService); + $deprecatedTable->setReadonly()->highlightService($roService); } $this->content()->add($table); + $this->content()->add($deprecatedTable); } } @@ -426,12 +457,50 @@ public function appliedserviceAction() $parent = IcingaService::loadWithAutoIncId($serviceId, $db); $serviceName = $parent->getObjectName(); + $this->addTitle( + $this->translate('Applied service: %s'), + $serviceName, + ); + + $deactivateForm = (new HostServiceBlacklistForm($db, $host, $parent)) + ->on(HostServiceBlacklistForm::ON_SUCCESS, function () { + $this->redirectNow(Url::fromRequest()); + }) + ->handleRequest($this->getServerRequest()); + + if ($deactivateForm->hasBeenBlacklisted()) { + $this->content()->add( + Hint::warning($this->translate('This Service has been deactivated on this host')) + ); + $this->content()->add($deactivateForm); + } else { + $this->controls()->prepend($deactivateForm); + $form = $this->prepareCustomPropertiesForm($parent, $host); + $form->setApplyGenerated($parent); + $form->setHostForService($host); + $this->content()->add($form->handleRequest($this->getServerRequest())); + } + + $this->commonForServices(); + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + public function appliedservicedeprecatedAction() + { + $db = $this->db(); + $host = $this->getHostObject(); + $serviceId = $this->params->get('service_id'); + $parent = IcingaService::loadWithAutoIncId($serviceId, $db); + $serviceName = $parent->getObjectName(); + $service = IcingaService::create([ - 'imports' => $parent, + 'imports' => $parent, 'object_type' => 'apply', 'object_name' => $serviceName, - 'host_id' => $host->get('id'), - 'vars' => $host->getOverriddenServiceVars($serviceName), + 'host_id' => $host->get('id'), + 'vars' => $host->getOverriddenServiceVars($serviceName), ], $db); $this->addTitle( @@ -455,16 +524,19 @@ public function appliedserviceAction() /** * @throws \Icinga\Exception\NotFoundError */ - public function inheritedserviceAction() + public function inheritedservicedeprecatedAction() { $db = $this->db(); $host = $this->getHostObject(); $serviceName = $this->params->get('service'); - $from = IcingaHost::load($this->params->get('inheritedFrom'), $this->db()); + $from = IcingaHost::load( + $this->params->get('inheritedFrom'), + $this->db() + ); $parent = IcingaService::load([ 'object_name' => $serviceName, - 'host_id' => $from->get('id') + 'host_id' => $from->get('id') ], $this->db()); // TODO: we want to eventually show the host template name, doesn't work @@ -474,12 +546,15 @@ public function inheritedserviceAction() $service = IcingaService::create([ 'object_type' => 'apply', 'object_name' => $serviceName, - 'host_id' => $host->get('id'), - 'imports' => [$parent], - 'vars' => $host->getOverriddenServiceVars($serviceName), + 'host_id' => $host->get('id'), + 'imports' => [$parent], + 'vars' => $host->getOverriddenServiceVars($serviceName), ], $db); - $this->addTitle($this->translate('Inherited service: %s'), $serviceName); + $this->addTitle( + $this->translate('Inherited service: %s'), + $serviceName + ); $form = IcingaServiceForm::load() ->setDb($db) @@ -492,6 +567,53 @@ public function inheritedserviceAction() $this->commonForServices(); } + /** + * @throws \Icinga\Exception\NotFoundError + */ + public function inheritedserviceAction() + { + $host = $this->getHostObject(); + $serviceName = $this->params->get('service'); + $db = $this->db(); + $from = IcingaHost::load($this->params->get('inheritedFrom'), $db); + $parent = IcingaService::load([ + 'object_name' => $serviceName, + 'host_id' => $from->get('id'), + ], $db); + $deactivateForm = (new HostServiceBlacklistForm($db, $host, $parent)) + ->on(HostServiceBlacklistForm::ON_SUCCESS, function () { + $this->redirectNow(Url::fromRequest()); + }) + ->handleRequest($this->getServerRequest()); + + $this->addTitle( + $this->translate('Inherited service: %s'), + $serviceName + ); + if ($deactivateForm->hasBeenBlacklisted()) { + $this->content()->add( + Hint::warning( + $this->translate( + 'This Service has been deactivated on this host' + ) + ) + ); + + $this->content()->add($deactivateForm); + } else { + $this->controls()->prepend($deactivateForm); + $form = $this->prepareCustomPropertiesForm($parent, $host); + $form->setInheritedServiceFrom($from); + $form->setHostForService($host); + + $this->content()->add( + $form->handleRequest($this->getServerRequest()) + ); + } + + $this->commonForServices(); + } + /** * @throws \Icinga\Exception\NotFoundError */ @@ -511,7 +633,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 +661,73 @@ 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_SUCCESS, function () { + $this->redirectNow(Url::fromRequest()); + }) + ->handleRequest($this->getServerRequest()); + + if ($deactivateForm->hasBeenBlacklisted()) { + $this->content()->add(Hint::warning($this->translate('This Service has been deactivated on this host'))); + $this->content()->add($deactivateForm); + } else { + $this->controls()->prepend($deactivateForm); + $form = $this->prepareCustomPropertiesForm($originalService, $host); + $form->setServiceSet($setTemplate); + $form->setHostForService($host); + + $this->tabs()->activate('services'); + $this->content()->add( + $form->handleRequest($this->getServerRequest()) + ); + } + + $this->commonForServices(); + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + public function servicesetservicedeprecatedAction() + { + $db = $this->db(); + $host = $this->getHostObject(); + $serviceName = $this->params->get('service'); + $setParams = [ + 'object_name' => $this->params->get('set'), + 'host_id' => $host->get('id'), + ]; + $setTemplate = IcingaServiceSet::load($this->params->get('set'), $db); + if (IcingaServiceSet::exists($setParams, $db)) { + $set = IcingaServiceSet::load($setParams, $db); + } else { + $set = $setTemplate; + } + + $service = IcingaService::load([ + 'object_name' => $serviceName, 'service_set_id' => $setTemplate->get('id') ], $this->db()); $service = IcingaService::create([ - 'id' => $service->get('id'), + 'id' => $service->get('id'), 'object_type' => 'apply', 'object_name' => $serviceName, - 'host_id' => $host->get('id'), - 'imports' => $service->listImportNames(), - 'vars' => $host->getOverriddenServiceVars($serviceName), + 'host_id' => $host->get('id'), + 'imports' => $service->listImportNames(), + 'vars' => $host->getOverriddenServiceVars($serviceName), ], $db); // $set->copyVarsToService($service); 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..208e356e6 --- /dev/null +++ b/application/controllers/VariablesController.php @@ -0,0 +1,83 @@ +addTitleTab($this->translate('Custom Variables')); + + $db = Db::fromResourceName( + Config::module('director')->get('db', 'resource') + )->getDbAdapter(); + + $query = $db->select() + ->from(['dp' => 'director_property'], []) + ->joinLeft(['ihp' => 'icinga_host_property'], 'ihp.property_uuid = dp.uuid', []) + ->joinLeft(['isp' => 'icinga_service_property'], 'isp.property_uuid = dp.uuid', []) + ->joinLeft(['iup' => 'icinga_user_property'], '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..7538f4bdd --- /dev/null +++ b/application/forms/CustomVariableForm.php @@ -0,0 +1,631 @@ +getAttributes()->add(['class' => ['custom-variable-form']]); + } + + /** + * Get the UUID of the property + * + * @return ?UuidInterface + */ + public function getUUid(): ?UuidInterface + { + return $this->uuid; + } + + /** + * Get UUID of the parent property + * + * @return ?UuidInterface + */ + public function getParentUUid(): ?UuidInterface + { + return $this->parentUuid; + } + + /** + * Set whether to hide the key name element or not (checked for the fixed array) + * + * @param bool $hideKeyNameElement + * + * @return $this + */ + public function setHideKeyNameElement(bool $hideKeyNameElement): self + { + $this->hideKeyNameElement = $hideKeyNameElement; + + return $this; + } + + /** + * Set whether the field is a nested field (field in a sub dictionary) or not + * + * @param bool $isNestedField + * + * @return $this + */ + public function setIsNestedField(bool $isNestedField): self + { + $this->isNestedField = $isNestedField; + + return $this; + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addElement('hidden', 'used_count', ['ignore' => true]); + $used = (int) $this->getValue('used_count') > 0; + + if ($this->hideKeyNameElement) { + $db = $this->db->getDbAdapter(); + $query = $db->select() + ->from('director_property', ['count' => 'COUNT(*)']) + ->where('parent_uuid = ?', Db\DbUtil::quoteBinaryCompat($this->parentUuid->getBytes(), $db)); + + $this->addElement( + 'hidden', + 'key_name', + [ + 'label' => $this->translate('Property Key *'), + 'required' => true, + 'value' => $db->fetchOne($query) + ] + ); + } else { + $this->addElement( + 'text', + 'key_name', + [ + 'label' => $this->translate('Property Key *'), + 'required' => true + ] + ); + } + + $this->addElement( + 'text', + 'label', + [ + 'label' => $this->translate('Property Label'), + 'required' => $this->hideKeyNameElement + ] + ); + + $this->addElement( + 'textarea', + 'description', + ['label' => $this->translate('Property Description')] + ); + + if ($this->parentUuid === null) { + $this->addElement( + 'select', + 'category_id', + [ + 'label' => $this->translate('Category'), + 'value' => '', + 'options' => ['' => $this->translate('- please choose -')] + $this->fetchCategories() + ] + ); + } + + $types = [ + 'string' => 'String', + 'number' => 'Number', + 'bool' => 'Boolean', + 'datalist-strict' => 'Data List Strict', + 'datalist-non-strict' => 'Data List Non Strict', + ]; + + if (! $this->isNestedField) { + $types += [ + 'fixed-array' => 'Fixed Array', + 'dynamic-array' => 'Dynamic Array', + 'fixed-dictionary' => 'Fixed Dictionary' + ]; + + if ($this->parentUuid === null) { + $types += [ + 'dynamic-dictionary' => 'Dynamic Dictionary' + ]; + } + } + + $this->addElement( + 'select', + 'value_type', + [ + 'label' => $this->translate('Property Type *'), + 'class' => 'autosubmit', + 'required' => true, + 'disabledOptions' => [''], + 'value' => 'string', + 'options' => $types, + 'disabled' => $used, + 'title' => $used ? $this->translate( + 'This property is used in one or more templates and hence the value type' + . ' cannot be changed.' + ) : '', + ] + ); + + $type = $this->getValue('value_type'); + if ($type === 'dynamic-array') { + $this->addElement( + 'select', + 'item_type', + [ + 'label' => $this->translate('Item Type'), + 'class' => 'autosubmit', + 'disabledOptions' => [''], + 'value' => 'string', + 'options' => array_slice($types, 0, 2), + 'disabled' => $used, + 'title' => $used ? $this->translate( + 'This property is used in one or more templates and hence the item type' + . ' cannot be changed.' + ) : '' + ] + ); + } elseif (str_starts_with($type, 'datalist')) { + $isStrict = substr_compare($type, 'strict', strlen('datalist-')) === 0; + $this->getElement('value_type')->setAttribute('strict', $isStrict); + $this->addElement( + 'select', + 'list', + [ + 'label' => $this->translate('List name'), + 'class' => 'autosubmit', + 'disabledOptions' => [''], + 'value' => '', + 'required' => true, + 'options' => ['' => $this->translate('- please choose -')] + $this->enumDatalist(), + 'disabled' => $used, + 'title' => $used ? $this->translate( + 'This property is used in one or more templates and hence the datalist' + . ' cannot be changed.' + ) : '' + ] + ); + + $this->addElement( + 'select', + 'item_type', + [ + 'label' => $this->translate('Item Type'), + 'class' => 'autosubmit', + 'disabledOptions' => [''], + 'value' => 'string', + 'options' => ['string' => 'String', 'dynamic-array' => 'Array'] + ] + ); + + if ($used) { + $this->getElement('item_type') + ->setAttribute( + 'title', + $this->translate( + 'This property is used in one or more templates and hence the item type cannot be changed.' + ) + ) + ->setAttribute('disabled', true); + } + } + + $this->addElement('submit', 'submit', [ + 'label' => $this->uuid ? $this->translate('Save') : $this->translate('Add') + ]); + + if ($this->uuid) { + // TODO: Ask for confirmation before deleting + $this->getElement('submit') + ->getWrapper() + ->prepend( + (new ButtonLink( + $this->translate('Delete'), + Url::fromPath( + 'director/customvar/delete', + ['uuid' => $this->uuid->toString()] + ), + null, + ['class' => ['btn-remove']] + ))->openInModal() + ); + } + } + + /** + * 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']); + } + + $this->db->getDbAdapter()->beginTransaction(); + if ($this->uuid === null) { + $this->addNewProperty($values, $datalist, $itemType); + } else { + $this->updateExistingProperty($values, $datalist, $itemType); + } + + $this->db->getDbAdapter()->commit(); + } + + /** + * Add a new custom 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) { + $this->db->delete( + 'director_property', + Filter::matchAll(Filter::where( + 'parent_uuid', + Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()) + )) + ); + + $this->db->delete( + 'director_property_datalist', + Filter::matchAll(Filter::where( + 'property_uuid', + Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()) + )) + ); + } + + if ($itemType && ($valueType === 'dynamic-array' || str_starts_with($valueType, 'datalist-'))) { + $this->db->insert('director_property', [ + 'uuid' => Db\DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $this->db->getDbAdapter()), + 'key_name' => '0', + 'value_type' => $itemType, + 'parent_uuid' => Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()), + ]); + + if (str_starts_with($valueType, 'datalist-')) { + $this->db->insert('director_property_datalist', [ + 'property_uuid' => Db\DbUtil::quoteBinaryCompat( + $this->uuid->getBytes(), + $this->db->getDbAdapter() + ), + 'list_uuid' => Db\DbUtil::quoteBinaryCompat( + Db\DbUtil::binaryResult($datalist['uuid']), + $this->db->getDbAdapter() + ), + ]); + } + } + } else { + $storedKeyName = $this->db->fetchOne( + $this->db->select() + ->from('director_property', ['key_name']) + ->where( + 'uuid = ?', + Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()) + ) + ); + + if ($storedKeyName !== $values['key_name']) { + $this->updateUsedCustomVarNames($storedKeyName, $values['key_name']); + } + } + + $this->db->update( + 'director_property', + $values, + Filter::where('uuid', Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter())) + ); + } + + /** + * Update the used custom variable names in the database + * + * @param string $storedKeyName + * @param mixed $keyName + * + * @return void + */ + private function updateUsedCustomVarNames(string $storedKeyName, mixed $keyName): void + { + $db = $this->db->getDbAdapter(); + $parent = []; + if (! $this->parentUuid) { + $rootUuid = $this->uuid; + } elseif ($this->isNestedField) { + $parent = $this->fetchProperty($this->parentUuid); + $rootUuid = Uuid::fromBytes(Db\DbUtil::binaryResult($parent['parent_uuid'])); + } else { + $rootUuid = $this->parentUuid; + } + + $root = $this->fetchProperty($rootUuid); + $objectTypes = ['host', 'service', 'notification', 'command', 'user']; + + foreach ($objectTypes as $objectType) { + $objectCustomVars = $db->fetchAll( + $db->select() + ->from(['ihv' => "icinga_{$objectType}_var"], []) + ->columns([ + "{$objectType}_id", + 'varname', + 'varvalue', + 'property_uuid' + ]) + ->where('property_uuid = ?', Db\DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), + [], + PDO::FETCH_ASSOC + ); + + if (! $this->parentUuid) { + foreach ($objectCustomVars as $objectCustomVar) { + $this->db->update( + "icinga_{$objectType}_var", + ['varname' => $keyName], + Filter::matchAll( + Filter::where('property_uuid', Db\DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), + Filter::where("{$objectType}_id", $objectCustomVar["{$objectType}_id"]) + ) + ); + } + + return; + } + + foreach ($objectCustomVars as $objectCustomVar) { + $varValue = json_decode($objectCustomVar['varvalue'], true); + if ($root['value_type'] !== 'dynamic-dictionary') { + $this->updateObjectCustomVars([$storedKeyName], [$keyName], $varValue); + } else { + foreach ($varValue as $key => $value) { + if (! $this->isNestedField) { + $this->updateObjectCustomVars([$storedKeyName], [$keyName], $value); + } else { + $parenKey = $parent['key_name']; + $this->updateObjectCustomVars( + [$parenKey, $storedKeyName], + [$parenKey, $keyName], + $value + ); + } + + $varValue[$key] = $value; + } + } + + $this->db->update( + "icinga_{$objectType}_var", + ['varvalue' => json_encode($varValue)], + Filter::matchAll( + Filter::where('property_uuid', Db\DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), + Filter::where("{$objectType}_id", $objectCustomVar["{$objectType}_id"]) + ) + ); + } + } + } +} diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php new file mode 100644 index 000000000..5fc1200a5 --- /dev/null +++ b/application/forms/CustomVariablesForm.php @@ -0,0 +1,413 @@ +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; + } + + protected function assemble(): void + { + $this->addCsrfCounterMeasure(Session::getSession()->getId()); + $dictionary = (new Dictionary( + 'properties', + $this->objectProperties, + ['class' => 'no-border'] + ))->setAllowItemRemoval($this->object->isTemplate()); + + $saveButton = $this->createElement('submit', 'save', [ + 'label' => $this->isOverrideServiceVars() + ? $this->translate('Override Custom Variables') + : $this->translate('Save Custom Variables') + ]); + + $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); + + $this->addElement($this->duplicateSubmitButton($saveButton)); + $this->addElement($dictionary); + if ($this->hasBeenSent()) { + $dictionary->ensureAssembled(); + } + + $this->addHtml($addedUuidsContainer); + $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); + } + + 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 ($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 + { + return array_filter( + array_map(function ($item) { + if (! is_array($item)) { + // Recursively clean nested arrays + return $item; + } + + return self::filterEmpty($item); + }, $array), + function ($item) { + return is_bool($item) || ! empty($item); + } + ); + } + + protected function onSuccess(): void + { + $vars = $this->object->vars(); + + /** @var Dictionary $propertiesElement */ + $propertiesElement = $this->getElement('properties'); + $values = $propertiesElement->getDictionary(); + $itemsToRemove = $propertiesElement->getItemsToRemove(); + $type = $this->object->getShortTableName(); + $db = $this->object->getDb(); + $itemsToRemoveUuids = []; + 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; + } + + $value = $values[$key] ?? null; + + if (is_array($value)) { + $filteredValue = self::filterEmpty($value); + // Store the fixed array as empty only if the filtered array is empty + if ($property['value_type'] !== 'fixed-array' || empty($filteredValue)) { + $value = $filteredValue; + } + } + + if (isset($property['new'])) { + $this->varsHasBeenModified = true; + $this->object->getConnection()->insert( + "icinga_$type" . '_property', + [ + $type . '_uuid' => DbUtil::quoteBinaryCompat($this->object->uuid, $db), + 'property_uuid' => DbUtil::quoteBinaryCompat($propertyUuid->getBytes(), $db) + ] + ); + } + + if (! is_bool($value) && empty($value)) { + $vars->set($key, null); + } else { + $vars->set($key, $value); + } + + if ($vars->get($key) && $vars->get($key)->getUuid() === null && isset($property['uuid'])) { + $vars->registerVarUuid($key, $propertyUuid); + } + + if ($this->varsHasBeenModified === false && $vars->hasBeenModified()) { + $this->varsHasBeenModified = true; + } + } + + if (! empty($itemsToRemove)) { + $objectId = (int) $this->object->get('id'); + $db = $this->object->getDb(); + + $objectsToCleanUp = [$objectId]; + $propertyAsObjectVar = $db->fetchAll( + $db + ->select() + ->from('icinga_' . $type . '_var') + ->where('property_uuid IN (?)', DbUtil::quoteBinaryCompat($itemsToRemoveUuids, $db)) + ); + + foreach ($propertyAsObjectVar as $propertyAsObjectVarRow) { + $class = DbObjectTypeRegistry::classByType($type); + $object = $class::loadWithAutoIncId( + $propertyAsObjectVarRow->{$type . '_id'}, + $this->object->getConnection() + ); + + if (in_array($objectId, $object->listAncestorIds(), true)) { + $objectsToCleanUp[] = (int) $object->get('id'); + } + } + + $propertyWhere = $this->object->getDb()->quoteInto('property_uuid IN (?)', $itemsToRemoveUuids); + $objectsWhere = $this->object->getDb()->quoteInto($type . '_id IN (?)', $objectsToCleanUp); + $db->delete('icinga_' . $type . '_var', $propertyWhere . ' AND ' . $objectsWhere); + + $objectWhere = $this->object->getDb()->quoteInto( + $type . '_uuid = ?', + DbUtil::quoteBinaryCompat($this->object->get('uuid'), $db) + ); + $db->delete( + 'icinga_' . $type . '_property', + $propertyWhere . ' AND ' . $objectWhere + ); + } + + if ($this->isOverrideServiceVars()) { + $object = $this->host; + $overrideVars = (array) $this->host->getOverriddenServiceVars($this->object->getObjectName()); + foreach ($vars as $varName => $var) { + if ($var->hasBeenModified()) { + $overrideVars[$varName] = $var->getValue(); + } + } + + $object->overrideServiceVars($this->object->getObjectName(), (object) $overrideVars); + DirectorActivityLog::logModification($object, $this->object->getConnection()); + + $object->store($this->object->getConnection()); + } else { + $object = $this->object; + DirectorActivityLog::logModification($object, $this->object->getConnection()); + $vars->storeToDb($object); + } + } +} diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php new file mode 100644 index 000000000..56c379d1d --- /dev/null +++ b/application/forms/DeleteCustomVariableForm.php @@ -0,0 +1,507 @@ +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->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->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Delete'), + 'class' => 'btn-remove' + ]); + } + + /** + * Fetch property for the given UUID + * + * @param UuidInterface $uuid UUID of the given property + * + * @return array + */ + private function fetchProperty(UuidInterface $uuid): array + { + $db = $this->db->getDbAdapter(); + + $query = $db + ->select() + ->from(['dp' => 'director_property'], []) + ->columns([ + 'key_name', + 'uuid', + 'parent_uuid', + 'value_type', + 'label', + 'description' + ]) + ->where('uuid = ?', DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + return DbUtil::normalizeRow($db->fetchRow($query, [], Zend_Db::FETCH_ASSOC) ?: []); + } + + /** + * Remove dictionary item from the give data array + * + * @param array $item + * @param array $path + * + * @return void + */ + private function removeDictionaryItem(array &$item, array $path): void + { + $key = array_shift($path); + + if (! array_key_exists($key, $item)) { + return; + } + + if (empty($path)) { + unset($item[$key]); + } elseif (is_array($item[$key])) { + $this->removeDictionaryItem($item[$key], $path); + } + + // Remove empty arrays (but not scalar zero/false values) + if (isset($item[$key]) && is_array($item[$key]) && empty($item[$key])) { + unset($item[$key]); + } + } + + protected function onSuccess(): void + { + $uuid = $this->property['uuid']; + $quotedUuid = DbUtil::quoteBinaryCompat($uuid, $this->db->getDbAdapter()); + $db = $this->db; + + $db->getDbAdapter()->beginTransaction(); + $prop = $this->property; + + if (str_starts_with($prop['value_type'], 'datalist-')) { + $db->delete('director_property_datalist', Filter::where('property_uuid', $quotedUuid)); + } + + $this->removeObjectCustomVars($prop, $this->parent); + $this->removeFromOverrideServiceVars($prop, $this->parent); + + $db->delete('director_property', Filter::where('uuid', $quotedUuid)); + $db->delete('director_property', Filter::where('parent_uuid', $quotedUuid)); + + $objects = ['host', 'service', 'notification', 'command', 'user']; + foreach ($objects as $object) { + $this->db->delete("icinga_{$object}_var", Filter::where('property_uuid', $quotedUuid)); + } + + $db->getDbAdapter()->commit(); + } + + /** + * Remove the deleted property's key from all hosts' _override_servicevars custom variable + * + * @param array $property The deleted property + * @param array $parent The parent property (empty for root properties) + * + * @return void + */ + private function removeFromOverrideServiceVars(array $property, array $parent): void + { + $db = $this->db->getDbAdapter(); + + // Get the configured override varname, falling back to the default + $overrideVarname = $db->fetchOne( + $db->select() + ->from('director_setting', ['setting_value']) + ->where('setting_name = ?', 'override_services_varname') + ) ?: '_override_servicevars'; + + // Determine the root property key, root type, and path within each service's root-key value + if (empty($parent)) { + // Root property deleted: remove its key_name from each service's override vars + $rootKeyName = $property['key_name']; + $rootType = $property['value_type']; + $pathWithinRootValue = null; + } elseif ($parent['parent_uuid'] === null) { + // Child field of a root property deleted + $rootKeyName = $parent['key_name']; + $rootType = $parent['value_type']; + $pathWithinRootValue = [$property['key_name']]; + } else { + // Nested child field deleted (grandparent is the root property) + $rootProp = $this->fetchProperty(Uuid::fromBytes($parent['parent_uuid'])); + $rootKeyName = $rootProp['key_name']; + $rootType = $rootProp['value_type']; + $pathWithinRootValue = [$parent['key_name'], $property['key_name']]; + } + + // Fetch all hosts that have the _override_servicevars custom variable + $query = $db->select() + ->from('icinga_host_var', ['host_id', 'varvalue']) + ->where('varname = ?', $overrideVarname); + + $rows = $db->fetchAll($query, [], Zend_Db::FETCH_ASSOC); + + foreach ($rows as $row) { + $overrideVars = json_decode($row['varvalue'], true); + if (! is_array($overrideVars)) { + continue; + } + + $modified = false; + foreach ($overrideVars as $serviceName => $serviceVars) { + if (! is_array($serviceVars) || ! array_key_exists($rootKeyName, $serviceVars)) { + continue; + } + + $modified = true; + + if ($pathWithinRootValue === null) { + // Root property deleted: remove its key from the service's override vars + unset($serviceVars[$rootKeyName]); + } elseif ($rootType === 'dynamic-dictionary') { + // Dynamic dictionary: remove the path from every dynamic entry + if (is_array($serviceVars[$rootKeyName])) { + foreach ($serviceVars[$rootKeyName] as $entryKey => $entryValue) { + if (! is_array($entryValue)) { + continue; + } + + $this->removeDictionaryItem($serviceVars[$rootKeyName][$entryKey], $pathWithinRootValue); + if (empty($serviceVars[$rootKeyName][$entryKey])) { + unset($serviceVars[$rootKeyName][$entryKey]); + } + } + } + + if (empty($serviceVars[$rootKeyName])) { + unset($serviceVars[$rootKeyName]); + } + } else { + // Fixed/static type: remove the nested path within the root key's value + $this->removeDictionaryItem($serviceVars[$rootKeyName], $pathWithinRootValue); + if (empty($serviceVars[$rootKeyName])) { + unset($serviceVars[$rootKeyName]); + } + } + + if (empty($serviceVars)) { + unset($overrideVars[$serviceName]); + } else { + $overrideVars[$serviceName] = $serviceVars; + } + } + + if (! $modified) { + continue; + } + + if (empty($overrideVars)) { + $db->delete('icinga_host_var', [ + 'host_id = ?' => $row['host_id'], + 'varname = ?' => $overrideVarname, + ]); + } else { + $db->update( + 'icinga_host_var', + ['varvalue' => json_encode($overrideVars)], + [ + 'host_id = ?' => $row['host_id'], + 'varname = ?' => $overrideVarname, + ] + ); + } + } + } + + private function removeObjectCustomVars(array $property, ?array $parent = null): void + { + if (empty($parent)) { + return; + } + + $db = $this->db->getDbAdapter(); + + if ($parent['parent_uuid'] !== null) { + // Parent is itself a field — grandparent is the root property + $rootUuid = Uuid::fromBytes($parent['parent_uuid']); + $rootProp = $this->fetchProperty($rootUuid); + $rootType = $rootProp['value_type']; + } else { + $rootUuid = Uuid::fromBytes($parent['uuid']); + $rootType = $parent['value_type']; + } + + // Path within the stored JSON to the key being deleted — constant for all rows + $path = [$property['key_name']]; + if ($parent['parent_uuid'] !== null) { + array_unshift($path, $parent['key_name']); + } + + // Re-index the fixed-array items in director_property once, before processing stored vars + $isParentFixedArray = $parent['value_type'] === 'fixed-array'; + $isRootFixedArray = $rootType === 'fixed-array'; + if ($isParentFixedArray) { + $this->updateFixedArrayItems(Uuid::fromBytes($parent['uuid'])); + } elseif ($isRootFixedArray) { + $this->updateFixedArrayItems($rootUuid); + } + + foreach (['host', 'service', 'notification', 'command', 'user'] as $objectType) { + $idColumn = "{$objectType}_id"; + $varRows = $db->fetchAll( + $db->select() + ->from(['iov' => "icinga_{$objectType}_var"], []) + ->columns([$idColumn, 'varname', 'varvalue']) + ->where('property_uuid = ?', DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), + [], + Zend_Db::FETCH_ASSOC + ); + + $objectClass = DbObjectTypeRegistry::classByType($objectType); + + foreach ($varRows as $varRow) { + $varValue = json_decode($varRow['varvalue'], true); + + if ($rootType !== 'dynamic-dictionary') { + $this->removeDictionaryItem($varValue, $path); + } else { + foreach ($varValue as $entryKey => $entryValue) { + if (! is_array($varValue[$entryKey])) { + continue; + } + + $this->removeDictionaryItem($varValue[$entryKey], $path); + + if ($varValue[$entryKey] === []) { + $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 ($isParentFixedArray) { + $varValue[$parent['key_name']] = array_values($varValue[$parent['key_name']]); + } elseif ($isRootFixedArray) { + $varValue = array_values($varValue); + } + + $vars->set($varRow['varname'], $varValue); + $vars->storeToDb($object); + } + } + } + + /** + * Update the items for the given fixed array + * + * @param UuidInterface $uuid + * + * @return void + */ + private function updateFixedArrayItems(UuidInterface $uuid): void + { + $db = $this->db->getDbAdapter(); + $quotedUuid = DbUtil::quoteBinaryCompat($uuid->getBytes(), $db); + $query = $db + ->select() + ->from(['dp' => 'director_property'], []) + ->columns([ + 'key_name', + 'uuid', + 'parent_uuid', + 'value_type', + 'label', + 'description' + ]) + ->where('parent_uuid = ?', $quotedUuid); + + $propItems = array_map([DbUtil::class, 'normalizeRow'], $db->fetchAll($query, [], Zend_Db::FETCH_ASSOC)); + + $db->delete( + 'director_property', + ['parent_uuid = ?' => $quotedUuid] + ); + + $count = 0; + foreach ($propItems as $propItem) { + $this->db->insert('director_property', [ + 'uuid' => Uuid::fromBytes($propItem['uuid'])->getBytes(), + 'parent_uuid' => $uuid->getBytes(), + 'key_name' => $count, + 'label' => $propItem['label'], + 'value_type' => $propItem['value_type'], + 'description' => $propItem['description'] + ]); + + $count++; + } + } +} diff --git a/application/forms/DictionaryElements/Dictionary.php b/application/forms/DictionaryElements/Dictionary.php new file mode 100644 index 000000000..72746af34 --- /dev/null +++ b/application/forms/DictionaryElements/Dictionary.php @@ -0,0 +1,255 @@ + '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; + } elseif (isset($this->items[$clearedItemName]['new'])) { + unset($this->items[$clearedItemName]); + } + } + + $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; + } + + /** + * Get the dictionary value + * + * @return array + */ + public function getDictionary(): array + { + $items = []; + + /** @var DictionaryItem $element */ + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof DictionaryItem) { + $item = $element->ensureAssembled()->getItem(); + if (isset($item['name']) && array_key_exists('value', $item)) { + $items[$item['name']] = $item['value']; + } + } + } + + return $items; + } +} diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php new file mode 100644 index 000000000..a501864a4 --- /dev/null +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -0,0 +1,489 @@ + ['no-border', 'dictionary-item']]; + + /** @var array Dictionary Item Fields */ + private $fields; + + /** @var ?FormElement Remove button */ + private ?FormElement $removeButton = null; + + public function __construct(string $name, array $items, $attributes = null) + { + $this->fields = $items; + + parent::__construct($name, $attributes); + } + + private static function fetchItemType(UuidInterface $uuid): string + { + $db = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); + $query = $db->select() + ->from( + ['dp' => 'director_property'], + ['value_type' => 'dp.value_type'] + ) + ->where('dp.parent_uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + return $db->fetchOne($query); + } + + /** + * Fetch datalist entries for a given property uuid. + * + * @param UuidInterface $uuid + * + * @return array + */ + private static function fetchDataListEntries(UuidInterface $uuid): array + { + $db = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); + $query = $db->select() + ->from( + ['dle' => 'director_datalist_entry'], + ['entry_name' => 'dle.entry_name', 'entry_value' => 'dle.entry_value'] + ) + ->join(['dl' => 'director_datalist'], 'dl.id = dle.list_id', []) + ->join(['dpl' => 'director_property_datalist'], 'dl.uuid = dpl.list_uuid', []) + ->where('dpl.property_uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); + + return $db->fetchPairs($query); + } + + protected function assemble(): void + { + 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->addHtml(new HtmlElement( + 'div', + null, + $this->removeButton + )); + } + + if ($label === null) { + $label = $this->getElement('name')->getValue(); + } + + $uuid = Uuid::fromBytes($this->fields['uuid']); + $children = static::fetchChildrenItems( + $uuid, + $this->fields['value_type'] ?? '' + ); + $inherited = $this->getElement('inherited')->getValue(); + $inheritedFrom = $this->getElement('inherited_from')->getValue(); + + $placeholder = ''; + if ($inherited) { + $placeholder = $inherited . ' (' . sprintf($this->translate('Inherited from %s'), $inheritedFrom) . ')'; + } + + if ($type === 'number') { + $this->addElement( + 'number', + $valElementName, + [ + 'label' => $label . ' (Number)', + 'placeholder' => $placeholder, + 'step' => 'any' + ] + ); + } elseif ($type == 'bool') { + $this->addElement( + new IplBoolean( + $valElementName, + ['label' => $label, 'placeholder' => $placeholder] + ) + ); + } 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] + ))->setUuid(Uuid::fromBytes($this->fields['uuid'])))->setLabel($label . ' (Dictionary)'); + } else { + $this->addElement( + 'text', + $valElementName, + [ + 'label' => $label . ' (' . ucfirst($type) . ')', + 'placeholder' => $placeholder + ] + ); + } + } + + 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']))); + + if (isset($datalistEntries[$values['var']])) { + $values['var-search'] = $datalistEntries[$values['var']]; + $values['var-label'] = $values['var']; + } else { + $values['var-search'] = $values['var']; + } + } + + parent::populate($values); + } + + /** + * Prepare the dictionary item for display + * + * @param array $property + * + * @return array + */ + public static function prepare(array $property): array + { + $values = [ + 'name' => $property['key_name'] ?? '', + 'label' => $property['label'] ?? '', + 'type' => $property['value_type'] ?? '', + 'parent_type' => $property['parent_type'] ?? '' + ]; + + $property['uuid'] = DbUtil::binaryResult($property['uuid'] ?? ''); + + if ( + $property['value_type'] === 'dynamic-array' + || ( + in_array($property['value_type'], ['datalist-strict', 'datalist-non-strict'], true) + && self::fetchItemType(Uuid::fromBytes($property['uuid'])) === 'dynamic-array' + ) + ) { + $values['var'] = $property['value'] ?? []; + $values['inherited'] = implode(', ', $property['inherited'] ?? []); + $values['inherited_from'] = $property['inherited_from'] ?? ''; + } elseif ($property['value_type'] === 'fixed-dictionary' || $property['value_type'] === 'fixed-array') { + $childrenValues = ['value' => $property['value'] ?? []]; + + if (! isset($property['value'])) { + $childrenValues['inherited'] = $property['inherited'] ?? []; + $childrenValues['inherited_from'] = $property['inherited_from'] ?? ''; + } + + $dictionaryItems = static::fetchChildrenItems( + Uuid::fromBytes($property['uuid']), + $property['value_type'], + $childrenValues + ); + $values['var'] = Dictionary::prepare($dictionaryItems); + } elseif ($property['value_type'] === 'dynamic-dictionary') { + $childrenValues = [ + 'value' => $property['value'] ?? [], + 'inherited' => $property['inherited'] ?? [], + 'inherited_from' => $property['inherited_from'] ?? '' + ]; + + $dictionaryItems = static::fetchChildrenItems( + Uuid::fromBytes($property['uuid']), + $property['value_type'], + $childrenValues + ); + $values['var'] = NestedDictionary::prepare( + $dictionaryItems, + $property['value'] ?? [] + ); + + $values['inherited'] = isset($property['inherited']) + ? json_encode($property['inherited'], JSON_PRETTY_PRINT) + : ''; + $values['inherited_from'] = $property['inherited_from'] ?? ''; + } elseif ( + $property['value_type'] === 'datalist-non-strict' + && self::fetchItemType(Uuid::fromBytes($property['uuid'])) === 'string' + ) { + $dataListEntries = self::fetchDataListEntries(Uuid::fromBytes($property['uuid'])); + $value = $property['value'] ?? ''; + if (isset($dataListEntries[$value])) { + $values['var'] = $dataListEntries[$value]; + $values['var-search'] = $value; + $values['var-label'] = $dataListEntries[$value]; + } else { + $values['var'] = $value; + $values['var-search'] = $value; + } + } else { + $values['var'] = $property['value'] ?? ''; + $values['inherited'] = $property['inherited'] ?? ''; + $values['inherited_from'] = $property['inherited_from'] ?? ''; + } + + return $values; + } + + /** + * Fetch children items of the given parent item + * + * @param UuidInterface $parentUuid + * @param string $parentType + * @param array $values + * + * @return array + */ + private static function fetchChildrenItems(UuidInterface $parentUuid, string $parentType, array $values = []): array + { + $db = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); + + $query = $db->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label', + 'parent_uuid' => 'dp.parent_uuid', + 'children' => 'COUNT(cdp.uuid)' + ] + ) + ->where('dp.parent_uuid = ?', Db\DbUtil::quoteBinaryCompat($parentUuid->getBytes(), $db)) + ->joinLeft( + ['cdp' => 'director_property'], + 'cdp.parent_uuid = dp.uuid', + [] + ) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->order('children') + ->order('key_name'); + + $propertyItems = $db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + foreach ($propertyItems as $key => $propertyItem) { + $propertyItem['uuid'] = DbUtil::binaryResult($propertyItem['uuid']); + $propertyItem['parent_uuid'] = DbUtil::binaryResult($propertyItem['parent_uuid']); + $propertyItems[$key] = $propertyItem; + } + + if (empty($values)) { + return $propertyItems; + } + + $result = []; + foreach ($propertyItems as $propertyItem) { + $propertyItem['parent_type'] = $parentType; + if (isset($values['value'][$propertyItem['key_name']])) { + $propertyItem['value'] = $values['value'][$propertyItem['key_name']]; + } + + if (isset($values['inherited'][$propertyItem['key_name']])) { + $propertyItem['inherited'] = $values['inherited'][$propertyItem['key_name']]; + $propertyItem['inherited_from'] = $values['inherited_from']; + } + + $result[$propertyItem['key_name']] = $propertyItem; + } + + return $result; + } + + /** + * Set the remove button. + * + * @param ?FormElement $removeButton + * + * @return $this + */ + public function setRemoveButton(?FormElement $removeButton): static + { + $this->removeButton = $removeButton; + + return $this; + } + + /** + * Get the dictionary item value + * + * @return DictionaryItemDataType + */ + public function getItem(): array + { + $values = ['name' => $this->getElement('name')->getValue()]; + $itemValue = $this->getElement('var'); + if ($itemValue instanceof NestedDictionary or $itemValue instanceof Dictionary) { + $values['value'] = $itemValue->getDictionary(); + + if ($this->getElement('type')->getValue() === 'fixed-array') { + $value = $values['value']; + ksort($value); + $values['value'] = array_values($value); + } + } elseif ( + $this->getElement('type')->getValue() === 'datalist-non-strict' + && self::fetchItemType(Uuid::fromBytes($this->fields['uuid'])) === 'string' + ) { + $values['value'] = $this->getElement('var-search')->getValue(); + } else { + if (! empty($this->getElement('inherited')->getValue())) { + $values['value'] = $itemValue->getValue(); + } else { + $defaultValue = null; + + // Use the default value for fixed-array items only if the fixed array does not have an inherited value + if ($this->getElement('parent_type')->getValue() === 'fixed-array') { + match ($this->getElement('type')->getValue()) { + 'string' => $defaultValue = '', + 'number' => $defaultValue = 0, + 'bool' => $defaultValue = 'n', + 'fixed-array', 'dynamic-array' => $defaultValue = [] + }; + } + + $values['value'] = $itemValue->getValue() ?? $defaultValue; + } + } + + $markForRemovalElement = 'delete-' . $this->getName(); + if ($this->hasElement($markForRemovalElement)) { + $values['delete'] = $this->getElement($markForRemovalElement)->getValue(); + } + + return $values; + } +} diff --git a/application/forms/DictionaryElements/NestedDictionary.php b/application/forms/DictionaryElements/NestedDictionary.php new file mode 100644 index 000000000..e46b87f01 --- /dev/null +++ b/application/forms/DictionaryElements/NestedDictionary.php @@ -0,0 +1,222 @@ + ['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; + + public function __construct( + string $name, + array $nestedItems, + array $inheritedValues, + $attributes = null + ) { + $this->inheritedValue = $inheritedValues; + $this->nestedItems = $nestedItems; + + 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()) { + $removedValue = $this->getPopulatedValue($count); + $clearedId = null; + if (isset($removedValue['id'])) { + $clearedId = $removedValue['id']; + } + + $this->clearPopulatedValue($remove->getName()); + $this->clearPopulatedValue($count); + + // Re-index populated values to ensure proper association with form data + foreach (range($count + 1, $expectedCount) as $i) { + $newPopulatedValue = $this->getPopulatedValue($count); + $newId = $newPopulatedValue['id'] ?? null; + $newPopulatedValue['id'] = $clearedId; + $this->populate([$i - 1 => $this->getPopulatedValue($i) ?? []]); + $clearedId = $newId; + } + } else { + $newCount++; + } + + $count++; + } + + $addButton = $this->createElement('submitButton', 'add_item', [ + 'label' => $this->translate('Add Item'), + 'class' => ['add-item'], + 'formnovalidate' => true + ]); + + 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++) { + $nestedDictionaryProperty = new NestedDictionaryItem($i, $this->nestedItems); + $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 + * + * @return array + */ + public function getDictionary(): array + { + $values = []; + $count = 0; + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof NestedDictionaryItem) { + $property = $element->getItem(); + if (! empty($property['key']) && array_key_exists('value', $property)) { + $values[$property['key']] = $property['value']; + } else { + $values[self::UNDEFINED_KEY . $count] = $property['value']; + } + + $count++; + } + } + + return $values; + } +} diff --git a/application/forms/DictionaryElements/NestedDictionaryItem.php b/application/forms/DictionaryElements/NestedDictionaryItem.php new file mode 100644 index 000000000..b291d0561 --- /dev/null +++ b/application/forms/DictionaryElements/NestedDictionaryItem.php @@ -0,0 +1,136 @@ + ['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']]) && ! empty($property[$nestedItem['key_name']])) { + $nestedItem['value'] = $property[$nestedItem['key_name']]; + } + + $nestedValues[] = $nestedItem; + } + + if (isset($property['key']) && str_starts_with($property['key'], NestedDictionary::UNDEFINED_KEY)) { + $property['key'] = null; + } + + return [ + 'key' => $property['key'], + 'var' => Dictionary::prepare($nestedValues) + ]; + } + + /** + * Get the nested dictionary item value + * + * @return NestedDictionaryItemDataType + */ + public function getItem(): array + { + $this->ensureAssembled(); + $key = $this->getElement('key')->getValue(); + $values = []; + $values['key'] = $key; + $values['value'] = $this->getElement('var')->getDictionary(); + + return $values; + } +} diff --git a/application/forms/HostServiceBlacklistForm.php b/application/forms/HostServiceBlacklistForm.php new file mode 100644 index 000000000..52c4de163 --- /dev/null +++ b/application/forms/HostServiceBlacklistForm.php @@ -0,0 +1,140 @@ + 'icinga-controls']; + + /** @var IcingaServiceSet Service set to which the service belongs */ + private $set; + + public function __construct( + protected DbConnection $db, + protected IcingaHost $host, + protected IcingaService $service + ) { + $this->addAttributes(Attributes::create(['class' => ['host-service-deactivate-form']])); + if (! $this->hasBeenBlacklisted()) { + $this->addAttributes(Attributes::create(['class' => ['active']])); + } else { + $this->addAttributes(Attributes::create(['class' => ['deactivated']])); + } + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $blacklisted = $this->hasBeenBlacklisted(); + $this->addElement('submit', 'submit', [ + 'label' => $blacklisted + ? $this->translate('Reactivate Service') + : $this->translate('Deactivate Service'), + 'class' => $blacklisted ? '' : 'btn-remove' + ]); + } + + protected function onSuccess(): void + { + if ($this->hasBeenBlacklisted()) { + if ($this->removeFromBlacklist()) { + Notification::success(sprintf( + $this->translate("Service '%s' on host '%s' has been reactivated"), + $this->service->getObjectName(), + $this->host->getObjectName() + )); + } + } else { + if ($this->blacklist()) { + Notification::success(sprintf( + $this->translate("Service '%s' on host '%s' has been deactivated"), + $this->service->getObjectName(), + $this->host->getObjectName() + )); + } + } + } + + /** + * Whether the service has been blacklisted or not + * + * @return bool + */ + public function hasBeenBlacklisted(): bool + { + if ($this->service === null) { + return false; + } + + if ($this->blackListed === false) { + // Safety check, branches + $hostId = $this->host->get('id'); + $serviceId = $this->service->get('id'); + if (! $hostId || ! $serviceId) { + return false; + } + + $db = $this->db->getDbAdapter(); + $this->blackListed = 1 === (int) $db->fetchOne( + $db->select()->from('icinga_host_service_blacklist', 'COUNT(*)') + ->where('host_id = ?', $hostId) + ->where('service_id = ?', $serviceId) + ); + } + + return $this->blackListed; + } + + /** + * Remove the service from blacklist for the host + * + * @return int + */ + protected function removeFromBlacklist(): int + { + $db = $this->db->getDbAdapter(); + $where = implode(' AND ', [ + $db->quoteInto('host_id = ?', $this->host->get('id')), + $db->quoteInto('service_id = ?', $this->service->get('id')), + ]); + + return $db->delete('icinga_host_service_blacklist', $where); + } + + /** + * Blacklist the service for the host + * + * @return int + * + * @throws Zend_Db_Adapter_Exception | IcingaException + */ + protected function blacklist(): int + { + $db = $this->db->getDbAdapter(); + $this->host->unsetOverriddenServiceVars($this->service->getObjectName())->store(); + + return $db->insert('icinga_host_service_blacklist', [ + 'host_id' => $this->host->get('id'), + 'service_id' => $this->service->get('id') + ]); + } +} diff --git a/application/forms/IcingaMultiEditForm.php b/application/forms/IcingaMultiEditForm.php index 7f0c0d11f..a2aa27180 100644 --- a/application/forms/IcingaMultiEditForm.php +++ b/application/forms/IcingaMultiEditForm.php @@ -47,12 +47,6 @@ public function pickElementsFrom(QuickForm $form, $properties) public function setup() { $object = $this->object; - - $loader = new IcingaObjectFieldLoader($object); - $loader->prepareElements($this); - $loader->addFieldsToForm($this); - $this->varNameMap = $loader->getNameMap(); - if ($form = $this->relatedForm) { if ($form instanceof DirectorObjectForm) { $form->setDb($object->getConnection()) diff --git a/application/forms/IcingaServiceForm.php b/application/forms/IcingaServiceForm.php index b97aa9a94..8b4c0db82 100644 --- a/application/forms/IcingaServiceForm.php +++ b/application/forms/IcingaServiceForm.php @@ -7,7 +7,6 @@ use Icinga\Exception\IcingaException; use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Auth\Permission; -use Icinga\Module\Director\Data\PropertiesFilter\ArrayCustomVariablesFilter; use Icinga\Module\Director\Exception\NestingError; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Web\Form\DirectorObjectForm; @@ -15,9 +14,12 @@ use Icinga\Module\Director\Objects\IcingaService; use Icinga\Module\Director\Objects\IcingaServiceSet; use Icinga\Module\Director\Web\Table\ObjectsTableHost; +use ipl\Html\Attributes; use ipl\Html\Html; use gipfl\IcingaWeb2\Link; use ipl\Html\HtmlElement; +use ipl\Html\Text; +use PDO; use RuntimeException; class IcingaServiceForm extends DirectorObjectForm @@ -41,9 +43,12 @@ class IcingaServiceForm extends DirectorObjectForm /** @var bool|null */ private $blacklisted; + /** @var ?IcingaHost */ private $blacklistedAncestor; + private $dictionaryUuidMap = []; + public function setApplyGenerated(IcingaService $applyGenerated) { $this->applyGenerated = $applyGenerated; @@ -672,6 +677,40 @@ 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->key_name . ' (' . $var->key_name . ')'; + if ($var->value_type === 'dynamic-dictionary') { + $this->dictionaryUuidMap['host.vars.' . $var->key_name] = $var->uuid; + } + } + + return [t('director', 'Custom variables') => $properties]; + } + /** * @return $this * @throws \Zend_Form_Exception @@ -679,24 +718,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..c13023575 --- /dev/null +++ b/application/forms/ObjectCustomvarForm.php @@ -0,0 +1,114 @@ +customVars = $this->getCustomVars(); + } + + public function getPropertyName(): string + { + $propertyUuid = $this->getValue('property'); + if ($propertyUuid) { + return $this->customVars[$propertyUuid] ?? ''; + } + + return ''; + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $propertyElement = $this->createElement( + 'select', + 'property', + [ + 'label' => $this->translate('Variable'), + 'required' => true, + 'class' => ['autosubmit'], + 'disabledOptions' => [''], + 'value' => '', + 'options' => array_merge( + ['' => $this->translate('Please choose a custom variable to add')], + $this->getCustomVars() + ) + ] + ); + + $this->addElement($propertyElement); + + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Add') + ]); + } + + /** + * 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..b560cb4af 100644 --- a/configuration.php +++ b/configuration.php @@ -175,3 +175,15 @@ ->setUrl('director/config/deployments') ->setPriority(902) ->setPermission(Permission::DEPLOYMENTS); +$section->add(N_('Custom Variables')) + ->setUrl('director/variables') + ->setPriority(903); + +$cssDirectory = $this->getCssDir(); +$cssFiles = new RecursiveIteratorIterator(new RecursiveDirectoryIterator( + $cssDirectory, + RecursiveDirectoryIterator::CURRENT_AS_PATHNAME | RecursiveDirectoryIterator::SKIP_DOTS +)); +foreach ($cssFiles as $path) { + $this->provideCssFile(ltrim(substr($path, strlen($cssDirectory)), DIRECTORY_SEPARATOR)); +} diff --git a/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 index 33b74e8c7..fb5bd33cf 100644 --- a/library/Director/Data/CustomVariableReferenceLoader.php +++ b/library/Director/Data/CustomVariableReferenceLoader.php @@ -6,10 +6,11 @@ use Icinga\Module\Director\Db; use Icinga\Module\Director\Objects\IcingaObject; use Ramsey\Uuid\Uuid; +use Zend_Db_Adapter_Abstract; class CustomVariableReferenceLoader { - /** @var Adapter|\Zend_Db_Adapter_Abstract */ + /** @var Adapter|Zend_Db_Adapter_Abstract */ protected $db; public function __construct(Db $connection) diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index 369fb49b5..fd2910e53 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -7,13 +7,17 @@ use Icinga\Exception\NotFoundError; use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\CustomVariable\CustomVariables; use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\DirectorObject\Lookup\ServiceFinder; use Icinga\Module\Director\Exception\DuplicateKeyException; use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Resolver\OverrideHelper; use InvalidArgumentException; +use PDO; +use Ramsey\Uuid\Uuid; use RuntimeException; class IcingaObjectHandler extends RequestHandler @@ -91,11 +95,54 @@ protected function processApiRequest() $this->sendJsonError($e); } - if ($this->request->getActionName() !== 'index') { + if ($this->request->getActionName() !== 'index' && $this->request->getActionName() !== 'variables') { throw new NotFoundError('Not found'); } } + /** + * Get the custom properties linked to the given object. + * + * @param IcingaObject $object + * + * @return array + */ + public function getObjectCustomProperties(IcingaObject $object): array + { + if ($object->get('uuid') === null) { + return []; + } + + $type = $object->getShortTableName(); + $db = $object->getConnection(); + $ids = $object->listAncestorIds(); + $ids[] = $object->get('id'); + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label' + ] + ) + ->join(['iop' => "icinga_$type" . '_property'], 'dp.uuid = iop.property_uuid', []) + ->join(['io' => "icinga_$type"], 'io.uuid = iop.' . $type . '_uuid', []) + ->where('io.id IN (?)', $ids) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->order('key_name'); + + $result = []; + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + $row = DbUtil::normalizeRow($row); + $result[$row['key_name']] = $row; + } + + return $result; + } + protected function handleApiRequest() { $request = $this->request; @@ -121,63 +168,174 @@ protected function handleApiRequest() $object = $this->requireObject(); $object->delete(); $this->sendJson($object->toPlainObject(false, true)); - break; + break; case 'POST': case 'PUT': $data = (array) $this->requireJsonBody(); $params = $this->request->getUrl()->getParams(); $allowsOverrides = $params->get('allowOverrides'); $type = $this->getType(); - if ($object = $this->loadOptionalObject()) { - if ($request->getMethod() === 'POST') { - $object->setProperties($data); - } else { - $data = array_merge([ - 'object_type' => $object->get('object_type'), - 'object_name' => $object->getObjectName() - ], $data); - $object->replaceWith(IcingaObject::createByType($type, $data, $db)); + $object = $this->loadOptionalObject(); + $actionName = $this->request->getActionName(); + + $overRiddenCustomVars = []; + if ($actionName === 'variables') { + $overRiddenCustomVars = $data; + } else { + // Extract custom vars from the data + if (isset($data['vars'])) { + $overRiddenCustomVars = (array) $data['vars']; + + unset($data['vars']); } - // Avoid cyclic imports for hosts and commands - if (in_array($object->getShortTableName(), ['host', 'command'], true)) { - if (in_array((int) $object->get('id'), $object->listAncestorIds())) { - throw new RuntimeException( - 'Import loop detected for the object ' - . $object->getObjectName() . ' -> Imports: ' - . implode(', ', $object->getImports()) - ); + foreach ($data as $key => $value) { + if (substr($key, 0, 5) === 'vars.') { + $overRiddenCustomVars[substr($key, 5)] = $value; + + unset($data[$key]); + } + } + + if ($object) { + if ($request->getMethod() === 'POST') { + $object->setProperties($data); + } else { + $data = array_merge([ + 'object_type' => $object->get('object_type'), + 'object_name' => $object->getObjectName() + ], $data); + $object->replaceWith(IcingaObject::createByType($type, $data, $db)); + } + + // Avoid cyclic imports for hosts and commands + if (in_array($object->getShortTableName(), ['host', 'command'], true)) { + if (in_array((int) $object->get('id'), $object->listAncestorIds())) { + throw new RuntimeException( + 'Import loop detected for the object ' + . $object->getObjectName() . ' -> Imports: ' + . implode(', ', $object->getImports()) + ); + } + + if (isset($data['imports']) && in_array($object->get('object_name'), $data['imports'])) { + throw new RuntimeException( + 'You can not import the same object into itself: ' . $object->getObjectName() + ); + } } - if (isset($data['imports']) && in_array($object->get('object_name'), $data['imports'])) { - throw new RuntimeException( - 'You can not import the same object into itself: ' . $object->getObjectName() - ); + $this->persistChanges($object); + } elseif ($allowsOverrides && $type === 'service') { + if ($request->getMethod() === 'PUT') { + throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); } + + $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data); + } else { + $object = IcingaObject::createByType($type, $data, $db); + $this->persistChanges($object); } + } - $this->persistChanges($object); + if (empty($overRiddenCustomVars)) { $this->sendJson($object->toPlainObject(false, true)); - } elseif ($allowsOverrides && $type === 'service') { - if ($request->getMethod() === 'PUT') { - throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); + + break; + } + + $objectVars = $object->vars(); + if ($this->object->get('id') && $request->getMethod() === 'PUT') { + $objectWhere = $db->getDbAdapter()->quoteInto("{$type}_id = ?", $this->object->get('id')); + $db->getDbAdapter()->delete( + 'icinga_' . $type . '_var', + $objectWhere + ); + + $uuidExpr = DbUtil::quoteBinaryCompat( + DbUtil::binaryResult($this->object->get('uuid')), + $db->getDbAdapter() + ); + $db->getDbAdapter()->delete( + 'icinga_' . $type . '_property', + $db->getDbAdapter()->quoteInto( + "{$type}_uuid = ?", + $uuidExpr + ) + ); + + $objectVars = new CustomVariables(); + } + + $customProperties = $this->getObjectCustomProperties($object); + + foreach ($overRiddenCustomVars as $key => $value) { + $objectVars->set($key, $value); + $objectVars->get($key)->setModified(); + if (isset($customProperties[$key])) { + $objectVars->registerVarUuid($key, Uuid::fromBytes($customProperties[$key]['uuid'])); + + continue; } - $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data); - } else { - $object = IcingaObject::createByType($type, $data, $db); - $this->persistChanges($object); - $this->sendJson($object->toPlainObject(false, true)); + + if ($actionName !== 'variables') { + continue; + } + + if (! $object->isTemplate()) { + throw new NotFoundError(sprintf( + 'The custom variable %s should be first added to one of the imported templates' + . ' for this object', + $key + )); + } + + if ($request->getMethod() === 'POST') { + $errMsg = sprintf( + 'The custom variable %s should be first added to the template', + $key + ); + + throw new NotFoundError($errMsg); + } + + $query = $db->getDbAdapter() + ->select() + ->from(['dp' => 'director_property'], ['uuid']) + ->where('dp.key_name = ? AND dp.parent_uuid IS NULL', $key); + $customPropertyUuid = DbUtil::binaryResult($db->getDbAdapter()->fetchOne($query)); + + if (! $customPropertyUuid) { + throw new NotFoundError(sprintf( + "'%s' is not configured in Icinga Director as a custom variable", + $key + )); + } + + $db->getDbAdapter()->insert( + 'icinga_' . $type . '_property', + [ + 'property_uuid' => DbUtil::quoteBinaryCompat($customPropertyUuid, $db->getDbAdapter()), + $type . '_uuid' => DbUtil::quoteBinaryCompat($object->get('uuid'), $db->getDbAdapter()) + ] + ); + + $objectVars->registerVarUuid($key, Uuid::fromBytes($customPropertyUuid)); } - break; + $objectVars->storeToDb($object); + $object = IcingaObject::loadByType($type, $object->getObjectName(), $db); + $this->sendJson($object->toPlainObject(false, true)); + + break; case 'GET': $object = $this->requireObject(); $exporter = new Exporter($this->db); RestApiParams::applyParamsToExporter($exporter, $this->request, $object->getShortTableName()); $this->sendJson($exporter->export($object)); - break; + break; default: $request->getResponse()->setHttpResponseCode(400); throw new IcingaException('Unsupported method ' . $request->getMethod()); diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index efa9f5972..34c061ebf 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,44 @@ 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'); + $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, + $nextSlotIndex + ) { + $newUuid = $form->getValue('property'); + if (! in_array($newUuid, $addedVarUuids, true)) { + $addedVarUuids[] = $newUuid; + } + + $redirectUrl = Url::fromPath( + 'director/' . $this->getType() . '/variables', + [ + 'uuid' => Uuid::fromBytes($object->get('uuid'))->toString(), + 'newVarUuid' => $newUuid, + 'nextSlotIndex' => $nextSlotIndex + ] + ); + $redirectUrl->getParams()->addValues('addedVarUuids', $addedVarUuids); + + $this->redirectNow($redirectUrl); + }) + ->handleRequest($this->getServerRequest()); + + $this->content()->add($form); + } + protected function addFieldsFormAndTable($object, $type) { $form = IcingaObjectFieldForm::load() @@ -296,6 +409,615 @@ 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', ''))) + )); + + $form = $this->prepareCustomPropertiesForm($object, null, $addedVarUuids); + + $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', 'newVarUuid', 'nextSlotIndex'])); + } + )->on( + CustomVariablesForm::ON_REQUEST, + function ( + ServerRequestInterface $request, + CustomVariablesForm $form + ) use ( + $object, + $newVarUuid, + $nextSlotIndex, + $addedVarUuids + ) { + if ($newVarUuid === null) { + return; + } + + $this->sendNewVarMultipartUpdate($object, $form, $newVarUuid, $nextSlotIndex, $addedVarUuids); + $this->params->remove('addedVarUuids'); + $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); + + $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 + * + * @return void + */ + private function sendNewVarMultipartUpdate( + IcingaObject $object, + CustomVariablesForm $form, + string $newVarUuid, + int $nextSlotIndex, + array $addedVarUuids + ): 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' => $row['uuid'], + 'value_type' => $row['value_type'], + 'label' => $row['label'], + 'allow_removal' => true, + 'new' => true, + $type . '_uuid' => $object->get('uuid') + ]; + + $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); + + $this->addPart( + (new ButtonLink( + $this->translate('Add Custom Variable'), + $buttonUrl->getAbsoluteUrl(), + null, + ['class' => 'control-button'] + ))->openInModal(), + 'add-custom-var-button' + ); + + // Update hidden addedVarUuids 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'); + } + + /** + * 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 + * + * @return ?CustomVariablesForm + */ + public function prepareCustomPropertiesForm( + IcingaObject $object, + ?IcingaHost $host = null, + array $addedVarUuids = [] + ): ?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); + $form = (new CustomVariablesForm($object, $objectProperties)) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->setAddedVarUuids($addedVarUuids); + 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']])) { + $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; + } + + $this->content()->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => ['apply-for-header']]), + HtmlElement::create( + 'div', + Attributes::create(['class' => ['apply-for-header-content']]), + [ + Text::create(sprintf( + $this->translate( + 'The values of selected host variable for apply-for-rule' + . ' is accessible through %s.' + ), + '$value$' + )) + ] + ) + )); + + if ($fetchVar->value_type !== 'dynamic-dictionary') { + return; + } + + $dictionaryKeys = $this->fetchNestedDictionaryKeys(DbUtil::binaryResult($fetchVar->uuid)); + if (empty($dictionaryKeys)) { + return; + } + + $content = []; + $configVariables = new HtmlElement('table', Attributes::create(['class' => 'key-value-table'])); + foreach ($dictionaryKeys as $keyAttributes) { + if (str_contains($keyAttributes['key_name'], ' ')) { + continue; + } + + if (preg_match('/[^a-zA-Z0-9_]/', $keyAttributes['key_name'])) { + $config = '$value["' . $keyAttributes['key_name'] . '"]'; + } else { + $config = '$value.' . $keyAttributes['key_name']; + } + + $content = [$this->createKey( + $keyAttributes['key_name'], + $keyAttributes['label'] ?? $keyAttributes['key_name'] + )]; + + if ($keyAttributes['value_type'] !== 'fixed-dictionary') { + $content[] = $this->createValue($config . '$'); + + $configVariables->addHtml(new HtmlElement( + 'tr', + Attributes::create(['class' => 'key-value-item']), + ...$content + )); + + continue; + } + + $nestedContent = []; + foreach ($this->fetchNestedDictionaryKeys($keyAttributes['uuid']) as $nestedKeyAttributes) { + if (str_contains($nestedKeyAttributes['key_name'], ' ')) { + continue; + } + + if (preg_match('/[^a-zA-Z0-9_]/', $nestedKeyAttributes['key_name'])) { + $nestedConfig = $config . '["' . $nestedKeyAttributes['key_name'] . '"]$'; + } else { + $nestedConfig = $config . '.' . $nestedKeyAttributes['key_name'] . '$'; + } + + $nestedContent[] = new HtmlElement('div', null, Text::create($nestedConfig)); + $nestedKeyName = $nestedKeyAttributes['key_name']; + $nestedLabel = $nestedKeyAttributes['label'] ?? $nestedKeyAttributes['key_name']; + $nestedContent = [ + $this->createKey($nestedKeyName, $nestedLabel), + $this->createValue($nestedConfig) + ]; + } + + if (preg_match('/[^a-zA-Z0-9_]/', $keyAttributes['key_name'])) { + $value = '$value["' . $keyAttributes['key_name'] . '"]$'; + } else { + $value = '$value.' . $keyAttributes['key_name'] . '$'; + } + + $content[] = new HtmlElement( + 'td', + Attributes::create(['class' => 'value']), + new HtmlElement( + 'div', + null, + new HtmlElement( + 'div', + null, + Text::create($value) + ), + new HtmlElement( + 'table', + Attributes::create(['class' => 'key-value-table']), + new HtmlElement( + 'tr', + Attributes::create( + ['class' => 'key-value-item'] + ), + ...$nestedContent + ) + ) + ) + ); + + $configVariables->addHtml( + new HtmlElement( + 'tr', + Attributes::create(['class' => 'key-value-item']), + ...$content + ) + ); + } + + if (empty($content)) { + return; + } + + $this->content()->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => ['apply-for-header']]), + HtmlElement::create( + 'div', + Attributes::create(['class' => ['apply-for-header-content']]), + [ + Text::create($this->translate( + 'Nested keys of selected host dictionary variable for apply-for-rule' + . ' are accessible through value as shown in the table below:' + )), + $configVariables + ] + ) + )); + } + + private function fetchNestedDictionaryKeys(string $dictionaryUuid) + { + $db = $this->db(); + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'uuid' => 'dp.uuid', + 'key_name' => 'dp.key_name', + 'label' => 'dp.label', + 'value_type' => 'dp.value_type' + ] + )->where("parent_uuid = ?", DbUtil::quoteBinaryCompat($dictionaryUuid, $db->getDbAdapter())); + + return $db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + } + + /** + * 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 + * + * @return array + */ + protected function getObjectCustomProperties( + IcingaObject $object, + bool $isOverrideVars = false, + array $addedVarUuids = [] + ): array { + if ($object->uuid === null) { + return []; + } + + $type = $object->getShortTableName(); + $parents = $object->listAncestorIds(); + + $uuids = []; + $db = $this->db(); + foreach ($parents as $parent) { + $uuids[] = DbUtil::quoteBinaryCompat( + IcingaObject::loadByType($type, $parent, $db)->get('uuid'), + $db->getDbAdapter() + ); + } + + $objectUuid = $object->get('uuid'); + $uuids[] = DbUtil::quoteBinaryCompat($objectUuid, $db->getDbAdapter()); + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + $type . '_uuid' => 'iop.' . $type . '_uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label', + 'children' => 'COUNT(cdp.uuid)' + ] + ) + ->join( + ['iop' => "icinga_$type" . '_property'], + 'dp.uuid = iop.property_uuid', + [] + ) + ->joinLeft( + ['cdp' => 'director_property'], + 'cdp.parent_uuid = dp.uuid', + [] + ) + ->where('iop.' . $type . '_uuid IN (?)', $uuids) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label', $type . '_uuid']) + ->order($this->valueTypeOrderExpr($db, [ + 'string', + 'number', + 'bool', + 'datalist-strict', + 'datalist-non-strict', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary' + ])) + ->order('children') + ->order('key_name'); + + 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']; + + 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; + } + + 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 +1489,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..65d884502 --- /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 = [ + null => $this->translate('- Please choose -'), + ] + $options; + } + + $this->setOptions($options); + } + + public function setValue($value) + { + if ($value === 'y' || $value === true) { + return parent::setValue('y'); + } elseif ($value === 'n' || $value === false) { + return parent::setValue('n'); + } + + return parent::setValue($value); + } + + public function getValue() + { + if ($this->value === 'y') { + return true; + } elseif ($this->value === 'n') { + return false; + } + + return $this->value ?? ''; + } + + protected function isSelectedOption($optionValue): bool + { + $optionValue = match ($optionValue) { + 'y' => true, + 'n' => false, + default => '', + }; + + return parent::isSelectedOption( + $optionValue + ); + } +} diff --git a/library/Director/Web/Form/IcingaObjectFieldLoader.php b/library/Director/Web/Form/IcingaObjectFieldLoader.php index 2e5a69a23..e0770f230 100644 --- a/library/Director/Web/Form/IcingaObjectFieldLoader.php +++ b/library/Director/Web/Form/IcingaObjectFieldLoader.php @@ -60,16 +60,6 @@ public function addFieldsToForm(DirectorObjectForm $form) return $this; } - /** - * Get element names to variable names map (Example: ['elName' => 'varName']) - * - * @return array - */ - public function getNameMap(): array - { - return $this->nameMap; - } - public function loadFieldsForMultipleObjects($objects) { $fields = array(); diff --git a/library/Director/Web/Table/IcingaHostAppliedServicesTable.php b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php index a372be598..246eb173d 100644 --- a/library/Director/Web/Table/IcingaHostAppliedServicesTable.php +++ b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php @@ -30,6 +30,7 @@ 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; /** diff --git a/library/Director/Web/Table/IcingaServiceSetServiceTable.php b/library/Director/Web/Table/IcingaServiceSetServiceTable.php index 6b704f73b..1ad374ef6 100644 --- a/library/Director/Web/Table/IcingaServiceSetServiceTable.php +++ b/library/Director/Web/Table/IcingaServiceSetServiceTable.php @@ -39,6 +39,7 @@ 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; /** @@ -103,6 +104,13 @@ 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; diff --git a/library/Director/Web/Table/ObjectsTableService.php b/library/Director/Web/Table/ObjectsTableService.php index 60a268375..667f870e3 100644 --- a/library/Director/Web/Table/ObjectsTableService.php +++ b/library/Director/Web/Table/ObjectsTableService.php @@ -20,6 +20,7 @@ class ObjectsTableService extends ObjectsTable protected $title; + /** @var bool Use deprecated links instead of the new links for the services */ private $useDeprecatedLink = false; /** @var IcingaHost */ @@ -63,6 +64,13 @@ 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; diff --git a/library/Director/Web/Tabs/ObjectTabs.php b/library/Director/Web/Tabs/ObjectTabs.php index 77a4654cc..74606d07c 100644 --- a/library/Director/Web/Tabs/ObjectTabs.php +++ b/library/Director/Web/Tabs/ObjectTabs.php @@ -7,6 +7,7 @@ use Icinga\Module\Director\Objects\IcingaObject; use ipl\I18n\Translation; use gipfl\IcingaWeb2\Widget\Tabs; +use Icinga\Module\Director\Objects\IcingaServiceSet; class ObjectTabs extends Tabs { @@ -98,7 +99,15 @@ protected function addTabsForExistingObject() $this->add('fields', array( 'url' => sprintf('director/%s/fields', $type), 'urlParams' => $params, - 'label' => $this->translate('Fields') + 'label' => $this->translate('Fields (Deprecated)') + )); + } + + if ($auth->hasPermission(Permission::ADMIN) && $this->hasCustomProperties()) { + $this->add('variables', array( + 'url' => sprintf('director/%s/variables', $type), + 'urlParams' => $params, + 'label' => $this->translate('Custom Variables') )); } @@ -157,4 +166,14 @@ protected function hasFields() && $object->supportsFields() && ($object->isTemplate() || $this->type === 'command'); } + + protected function hasCustomProperties() + { + if (! ($object = $this->object)) { + return false; + } + + return $object->hasBeenLoadedFromDb() + && $object->supportsCustomProperties(); + } } diff --git a/public/css/action-list.less b/public/css/action-list.less new file mode 100644 index 000000000..5bb08f4d1 --- /dev/null +++ b/public/css/action-list.less @@ -0,0 +1,14 @@ +.action-list { + [data-action-item]:hover { + background-color: @tr-hover-color; + cursor: pointer; + } + + [data-action-item].active { + background-color: @tr-active-color; + } + + &[data-icinga-multiselect-url] * { + user-select: none; + } +} diff --git a/public/css/custom-var-fields-table.less b/public/css/custom-var-fields-table.less new file mode 100644 index 000000000..5292b441f --- /dev/null +++ b/public/css/custom-var-fields-table.less @@ -0,0 +1,3 @@ +.common-table.custom-var-fields-table { + max-width: 100%; +} \ No newline at end of file diff --git a/public/css/custom-variable-form.less b/public/css/custom-variable-form.less new file mode 100644 index 000000000..92cdae5bb --- /dev/null +++ b/public/css/custom-variable-form.less @@ -0,0 +1,5 @@ +.custom-variable-form { + .btn-remove { + .button(@body-bg-color, @color-critical, @color-critical-accentuated); + } +} \ No newline at end of file diff --git a/public/css/custom-variables-form.less b/public/css/custom-variables-form.less new file mode 100644 index 000000000..de0ed59ec --- /dev/null +++ b/public/css/custom-variables-form.less @@ -0,0 +1,167 @@ +// 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 { + 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; + } + + // 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..b15f3dcd8 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -17,6 +17,51 @@ 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; + } +} + #layout.minimal-layout table.common-table td { padding-top: 0.5em; padding-bottom: 0.5em; @@ -490,6 +535,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 +994,19 @@ form.director-form { width: auto; } - p.description { + p.description:not(.deprecated-data-field) { color: @gray; font-style: italic; padding: 0.25em 0.5em; display: none; } - dd.active { - p.description { - font-style: normal; - display: block; - height: auto; - color: @text-color; - } + dd.active p.description, + dd p.description.deprecated-data-field { + font-style: normal; + display: block; + height: auto; + color: @text-color; } } diff --git a/public/js/module.js b/public/js/module.js index 07fe265dc..2d60fc35a 100644 --- a/public/js/module.js +++ b/public/js/module.js @@ -637,6 +637,10 @@ toggleFieldset: function (ev) { ev.stopPropagation(); var $fieldset = $(ev.currentTarget).closest('fieldset'); + if (! $fieldset.closest('form').hasClass('director-form')) { + return; + } + $fieldset.toggleClass('collapsed'); this.fixFieldsetInfo($fieldset); this.openedFieldsets[$fieldset.attr('id')] = ! $fieldset.hasClass('collapsed'); @@ -731,7 +735,7 @@ url = $container.data('icingaUrl'); $actions = $('.main-actions', $('#col1')); } - if (! $actions.length) { + if ($actions) { return; } @@ -786,6 +790,10 @@ restoreFieldsets: function (idx, form) { var $form = $(form); + if (! $form.hasClass('director-form')) { + return; + } + var self = this; var $sets = $('fieldset', $form); @@ -814,7 +822,7 @@ }, fixFieldsetInfo: function ($fieldset) { - if ($fieldset.hasClass('collapsed')) { + if ($fieldset.hasClass('collapsed') && $fieldset.closest('form').hasClass('director-form')) { if ($fieldset.find('legend span.element-count').length === 0) { var cnt = $fieldset.find('dt, li').not('.extensible-set li').length; if (cnt > 0) { From 695075ad479b7f042742556f5f7d9c1e5066e0e5 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 29 May 2026 14:22:26 +0200 Subject: [PATCH 004/221] Add basket import/export support for custom variables Extend the basket system to round-trip DirectorProperty definitions and their per-object assignments: - BasketSnapshotCustomVariableResolver: resolves UUIDs for properties and their fields during snapshot creation and restore; handles PostgreSQL binary UUIDs; recalculates binaries only when existingCustomPropertyUuids is non-empty; guards against missing properties before resolution - BasketSnapshot: wire in the resolver for custom variable sections - BasketDiff: detect changes in custom variable snapshot sections - Basket/ImportExport/Exporter/ObjectImporter: include custom variable references in export and import pipelines - Fix restoring of dictionary children whose parent property UUID must be re-linked after import --- application/controllers/BasketController.php | 2 +- library/Director/Data/Exporter.php | 15 +- library/Director/Data/ObjectImporter.php | 2 + .../DirectorObject/Automation/Basket.php | 3 +- .../DirectorObject/Automation/BasketDiff.php | 25 ++ .../Automation/BasketSnapshot.php | 47 ++- .../BasketSnapshotCustomVariableResolver.php | 305 ++++++++++++++++++ .../Automation/ImportExport.php | 11 + 8 files changed, 405 insertions(+), 5 deletions(-) create mode 100644 library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php 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/library/Director/Data/Exporter.php b/library/Director/Data/Exporter.php index 1a3cfcb7d..cf6b3566f 100644 --- a/library/Director/Data/Exporter.php +++ b/library/Director/Data/Exporter.php @@ -14,6 +14,7 @@ use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Objects\DirectorDatalistEntry; use Icinga\Module\Director\Objects\DirectorJob; +use Icinga\Module\Director\Objects\DirectorProperty; use Icinga\Module\Director\Objects\IcingaCommand; use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaObject; @@ -33,6 +34,9 @@ class Exporter /** @var FieldReferenceLoader */ protected $fieldReferenceLoader; + /** @var CustomVariableReferenceLoader */ + protected $propertyReferenceLoader; + /** @var ?HostServiceLoader */ protected $serviceLoader = null; @@ -52,6 +56,7 @@ public function __construct(Db $connection) $this->connection = $connection; $this->db = $connection->getDbAdapter(); $this->fieldReferenceLoader = new FieldReferenceLoader($connection); + $this->propertyReferenceLoader = new CustomVariableReferenceLoader($connection); } public function export(DbObject $object) @@ -75,7 +80,7 @@ public function export(DbObject $object) } if ($column = $object->getUuidColumn()) { if ($uuid = $object->get($column)) { - $props[$column] = Uuid::fromBytes($uuid)->toString(); + $props[$column] = Uuid::fromBytes(Db\DbUtil::binaryResult($uuid))->toString(); } } @@ -274,6 +279,13 @@ protected function exportDbObject(DbObject $object) $props['objects'] = JsonString::decode($props['objects']); } } + + if ($object instanceof DirectorProperty && $props['parent_uuid'] !== null) { + $props['parent_uuid'] = Uuid::fromBytes( + Db\DbUtil::binaryResult($props['parent_uuid']) + )->toString(); + } + unset($props['uuid']); // Not yet if (! $this->showDefaults) { foreach ($props as $key => $value) { @@ -296,6 +308,7 @@ protected function exportIcingaObject(IcingaObject $object) { $props = (array) $object->toPlainObject($this->resolveObjects, !$this->showDefaults); if ($object->supportsFields()) { + $props['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..1ff42f17a 100644 --- a/library/Director/Data/ObjectImporter.php +++ b/library/Director/Data/ObjectImporter.php @@ -56,6 +56,8 @@ public function import(string $implementation, stdClass $plain): DbObject $properties = (array) $plain; unset($properties['fields']); unset($properties['originalId']); + unset($properties['customVariables']); + if ($implementation === Basket::class) { if (isset($properties['objects']) && is_string($properties['objects'])) { $properties['objects'] = JsonString::decode($properties['objects']); diff --git a/library/Director/DirectorObject/Automation/Basket.php b/library/Director/DirectorObject/Automation/Basket.php index cfa71e7ef..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..c18504c78 100644 --- a/library/Director/DirectorObject/Automation/BasketDiff.php +++ b/library/Director/DirectorObject/Automation/BasketDiff.php @@ -25,6 +25,9 @@ class BasketDiff /** @var BasketSnapshotFieldResolver */ protected $fieldResolver; + /** @var BasketSnapshotCustomVariableResolver */ + protected $customPropertyResolver; + public function __construct(BasketSnapshot $snapshot, Db $db) { $this->db = $db; @@ -58,14 +61,28 @@ protected function getFieldResolver(): BasketSnapshotFieldResolver return $this->fieldResolver; } + protected function getCustomPropertyResolver(): BasketSnapshotCustomVariableResolver + { + if ($this->customPropertyResolver === null) { + $this->customPropertyResolver = new BasketSnapshotCustomVariableResolver( + $this->getBasketObjects(), + $this->db + ); + } + + return $this->customPropertyResolver; + } + protected function getCurrent(string $type, string $key, ?UuidInterface $uuid = null): ?stdClass { if ($uuid && $current = BasketSnapshot::instanceByUuid($type, $uuid, $this->db)) { $exported = $this->exporter->export($current); $this->getFieldResolver()->tweakTargetIds($exported); + $this->getCustomPropertyResolver()->tweakTargetUuids($exported); } elseif ($current = BasketSnapshot::instanceByIdentifier($type, $key, $this->db)) { $exported = $this->exporter->export($current); $this->getFieldResolver()->tweakTargetIds($exported); + $this->getCustomPropertyResolver()->tweakTargetUuids($exported); } else { $exported = null; } @@ -78,6 +95,7 @@ protected function getBasket($type, $key): stdClass { $object = $this->getBasketObject($type, $key); $fields = $object->fields ?? null; + $customVariables = $object->customVariables ?? null; $reExport = $this->exporter->export( $this->importer->import(BasketSnapshot::getClassForType($type), $object) ); @@ -86,6 +104,13 @@ protected function getBasket($type, $key): stdClass } else { $reExport->fields = $fields; } + + if ($customVariables === null) { + unset($reExport->customVariables); + } else { + $reExport->customVariables = $customVariables; + } + 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..4a21b09dd --- /dev/null +++ b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php @@ -0,0 +1,305 @@ +objects = (array) $objects; + $this->targetDb = $targetDb; + } + + /** + * Load all custom properties from the DB. + * + * @param Db $db + * + * @return DirectorProperty[] + */ + public function loadCurrentProperties(Db $db): array + { + $properties = []; + foreach ($this->getRequiredUuids() as $uuid) { + $properties[$uuid] = DirectorProperty::loadWithUniqueId(Uuid::fromString($uuid), $db); + } + + return $properties; + } + + /** + * Store new custom properties. + * + * @return void + */ + public function storeNewProperties(): void + { + $this->targetProperties = null; // Clear Cache + foreach ($this->getTargetProperties() as $uuid => $property) { + if ($property->hasBeenModified()) { + $property->store(); + $this->uuidMap[$uuid] = Uuid::fromBytes( + DbUtil::binaryResult($property->get('uuid')) + )->toString(); + } + + $modified = $this->restoreCustomPropertyItems($property); + if ($modified && ! isset($this->uuidMap[$uuid])) { + $this->uuidMap[$uuid] = Uuid::fromBytes( + DbUtil::binaryResult($property->get('uuid')) + )->toString(); + } + } + } + + /** + * Relink custom properties to the new object. + * + * @param IcingaObject $new + * @param $object + * + * @return void + */ + public function relinkObjectCustomProperties(IcingaObject $new, $object): void + { + if (! $new->supportsCustomProperties() || ! isset($object->customVariables)) { + return; + } + + $customPropertyMap = $this->getUuidMap(); + $db = $this->targetDb->getDbAdapter(); + $objectUuid = DbUtil::quoteBinaryCompat($new->get('uuid'), $db); + $type = $new->getShortTableName(); + + $table = $new->getTableName() . '_property'; + $objectKey = $type . '_uuid'; + $existingCustomProperties = []; + foreach ( + $db->fetchAll( + $db->select()->from($table)->where("$objectKey = ?", $objectUuid) + ) as $mapping + ) { + $propertyUuid = DbUtil::binaryResult($mapping->property_uuid); + $existingCustomProperties[Uuid::fromBytes($propertyUuid)->toString()] = $mapping; + } + + foreach ($object->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])) { + unset($existingCustomProperties[$uuid]); + } else { + $db->insert($table, [ + $objectKey => DbUtil::quoteBinaryCompat($new->get('uuid'), $db), + 'property_uuid' => DbUtil::quoteBinaryCompat(Uuid::fromString($uuid)->getBytes(), $db) + ]); + } + } + + 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; + } + + /** + * Get all objects of a certain type. + * + * @param $type + * + * @return object[] + */ + protected function getObjectsByType($type): array + { + if (! isset($this->objects[$type])) { + return []; + } + + return (array) $this->objects[$type]; + } + + /** + * Get all target properties. + * + * @return DirectorProperty[] + */ + protected function getTargetProperties(): array + { + if ($this->targetProperties === null) { + $this->calculateUuidMap(); + } + + return $this->targetProperties; + } + + /** + * Get the UUID map for object property UUIDs. + * + * @return array + */ + protected function getUuidMap(): array + { + if ($this->uuidMap === null) { + $this->calculateUuidMap(); + } + + return $this->uuidMap; + } + + /** + * Calculate the UUID map for object property UUIDs. + * + * @return void + */ + protected function calculateUuidMap(): void + { + $this->uuidMap = []; + $this->targetProperties = []; + foreach ($this->getObjectsByType('CustomVariable') as $uuid => $object) { + // Hint: import() doesn't store! + $new = DirectorProperty::import($object, $this->targetDb); + if ($new->hasBeenLoadedFromDb()) { + $newUuid = Uuid::fromBytes( + Db\DbUtil::binaryResult($new->get('uuid')) + )->toString(); + } else { + $newUuid = Uuid::uuid4()->toString(); + } + + $this->uuidMap[$uuid] = $newUuid; + $this->targetProperties[$uuid] = $new; + } + } + + private function restoreCustomPropertyItems(DirectorProperty $property): bool + { + $modified = false; + foreach ($property->fetchItemsFromDb() as $item) { + if ($item->hasBeenModified()) { + $item->store(); + $modified = true; + } + + if ($this->restoreCustomPropertyItems($item)) { + $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 = []; From 0366d5a7dd3c7ccde606714166e50bd6ba327ae8 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 29 May 2026 14:22:32 +0200 Subject: [PATCH 005/221] Add CLI commands for custom variable management - MigrateCommand: migrate legacy free-form custom vars to typed DirectorProperty definitions; supports --delete to remove the source datafields after migration, --dry-run with verbose messaging, skipped- field guard before exclusion, and PostgreSQL binary UUID handling - HostsCommand: bulk-manage host custom variable assignments from the CLI - ExportCommand: extend to include custom variable definitions in exports --- application/clicommands/ExportCommand.php | 21 + application/clicommands/HostsCommand.php | 93 ++++ application/clicommands/MigrateCommand.php | 529 +++++++++++++++++++++ 3 files changed, 643 insertions(+) create mode 100644 application/clicommands/MigrateCommand.php 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..a52a4f606 100644 --- a/application/clicommands/HostsCommand.php +++ b/application/clicommands/HostsCommand.php @@ -3,6 +3,10 @@ namespace Icinga\Module\Director\Clicommands; use Icinga\Module\Director\Cli\ObjectsCommand; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use PDO; +use Ramsey\Uuid\Uuid; /** * Manage Icinga Hosts @@ -11,4 +15,93 @@ */ 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 []; + } + + $type = $object->getShortTableName(); + + $parents = $object->listAncestorIds(); + + $uuids = []; + $db = $object->getConnection(); + + foreach ($parents as $parent) { + $uuids[] = IcingaHost::loadWithAutoIncId($parent, $db)->get('uuid'); + } + + $uuids[] = $object->get('uuid'); + $types = ['string', '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', + $type . '_uuid' => 'iop.' . $type . '_uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label', + 'children' => 'COUNT(cdp.uuid)' + ] + ) + ->join(['iop' => "icinga_$type" . '_property'], 'dp.uuid = iop.property_uuid', []) + ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) + ->where('iop.' . $type . '_uuid IN (?)', $uuids) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->order($valueTypeOrder) + ->order('children') + ->order('key_name'); + + $result = []; + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + $result[$row['key_name']] = $row; + } + + return $result; + } } diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php new file mode 100644 index 000000000..c5b0e5e54 --- /dev/null +++ b/application/clicommands/MigrateCommand.php @@ -0,0 +1,529 @@ + 'datafield_name'] */ + private $migratedDataFields = []; + + /** + * Run any pending migrations + * + * icingacli director migrate datafields --dry-run --delete --verbose + * + * - --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->checkMigrateableDatafieldTypes(); + $this->checkProtectedDatafields(); + $this->checkDatafieldsWithCategory(); + $this->checkUnmigrateableDatafieldTypes(); + $this->checkDatafieldsWithDuplicateNames(); + printf( + "Number of datafields that can not be migrated as the custom properties with the same name already" + . " exists: %d\n", + count($this->existingCustomProperties) + ); + } + + echo "Migrating Data fields\n"; + + foreach ($this->existingCustomProperties as $varname) { + unset($customPropertiesToMigrate[$varname]); + + if ($this->isVerbose) { + echo "[-] Skipping migrating datafield '$varname' as a custom 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->getDatafieldsWithProtectedValues() as $varname) { + echo "[-] Skipping migrating datafield '$varname' as it is protected\n"; + } + + foreach ($this->getDatafieldsWithDuplicateNames() as $varname => $count) { + printf( + "[-] Skipping migrating datafield '%s' as there are '%d' datafields with same name\n", + $varname, + $count + ); + } + } + + if (! empty($customPropertiesToMigrate)) { + $this->migrateDatafields($customPropertiesToMigrate, $dryRun); + } + + echo "Migration completed\n"; + + $totalMigrated = count($customPropertiesToMigrate); + $totalSkipped = count(DirectorDatafield::loadAll($db)) - $totalMigrated; + if ($delete) { + if (! $dryRun) { + $this->deleteMigratedDataFields(); + } + + 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 datafields migrated: %d\n", $totalMigrated); + printf("Total datafields skipped: %d\n", $totalSkipped); + } + + /** + * Prepare custom properties to migrate + * + * @return array + */ + private function prepareCustomProperties(): array + { + $db = $this->db(); + $directorProperty = DirectorProperty::loadAll( + $db, + $db->getDbAdapter()->select()->from('director_property')->where('parent_uuid IS NULL'), + 'key_name' + ); + + $customProperties = []; + $migrationQuery = $this->getDataFieldsMigrationQuery(); + $typeOffset = strlen("Icinga\Module\Director\DataType\DataType"); + foreach ($migrationQuery as $row) { + if (isset($directorProperty[$row->varname])) { + $this->existingCustomProperties[] = $row->varname; + + continue; + } + + $customProperty = [ + 'datafield_id' => $row->id, + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $row->varname, + 'label' => $row->caption, + 'description' => $row->description, + 'category_id' => $row->category_id + ]; + $dataType = strtolower(substr($row->datatype, $typeOffset)); + + if ($dataType === 'array') { + $customProperty['value_type'] = 'dynamic-array'; + $customProperty['item_type'] = 'string'; + } elseif ($dataType === 'boolean' || $dataType === 'number' || $dataType === 'string') { + $customProperty['value_type'] = $dataType === 'boolean' ? 'bool' : $dataType; + } elseif ($dataType === 'datalist') { + $datalist = DirectorDatafield::load($row->id, $db); + $settings = $datalist->getSettings(); + $behaviour = $settings['behavior']; + if ($behaviour === 'strict' || $behaviour === 'suggest_strict') { + $customProperty['value_type'] = 'datalist-strict'; + } else { + $customProperty['value_type'] = 'datalist-non-strict'; + } + + $customProperty['item_type'] = $settings['data_type'] === 'array' + ? 'dynamic-array' + : 'string'; + } else { + $customProperty['value_type'] = "unsupported-$dataType"; + } + + $customProperties[$row->varname] = $customProperty; + } + + return $customProperties; + } + + /** + * Migrate given prepared custom properties + * + * @param array $customProperties + * + * @return void + */ + private function migrateDatafields(array $customProperties, bool $dryRun): void + { + $db = $this->db(); + $dbAdapter = $db->getDbAdapter(); + if (! $dryRun) { + $dbAdapter->beginTransaction(); + } + + foreach ($customProperties as $varName => $customProperty) { + if ($this->isVerbose && str_starts_with($customProperty['value_type'], 'unsupported-')) { + echo "[-] Skipping migration of datafield '{$varName}' as it has an unsupported datatype '" + . substr($customProperty['value_type'], strlen('unsupported-')) + . "'\n"; + + continue; + } + + $itemType = null; + if (isset($customProperty['item_type'])) { + $itemType = $customProperty['item_type']; + unset($customProperty['item_type']); + } + + $this->migratedDataFields[$customProperty['datafield_id']] = $varName; + 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) + ]); + } + + $this->migrateDatafieldObjectTemplateBinding($datafieldId, $propertyUuid); + } + + if ($dryRun) { + echo "[*] Would migrate datafield '$varName'\n"; + } elseif ($this->isVerbose) { + echo "[+] Datafield '$varName' successfully migrated\n"; + } + } + + if (! $dryRun) { + $dbAdapter->commit(); + } + } + + /** + * Check what datafield types can be migrated + * + * @return void + */ + private function checkMigrateableDatafieldTypes(): void + { + $db = $this->db(); + printf("The following datafield types and the corresponding number of datafields can be migrated:\n"); + $total = 0; + $query = $this->getDataFieldsMigrationQuery(); + $typeOffset = strlen("Icinga\Module\Director\DataType\DataType"); + foreach ( + $db->select()->from( + ['q' => new DbSelectParenthesis($query->getSelectQuery())], + ['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 that can be migrated: %d\n\n", $total); + } + + /** + * Check what datafield types can not be migrated + * + * @return void + */ + private function checkUnmigrateableDatafieldTypes(): void + { + printf("The following datafield types and the corresponding number of datafields can not be migrated:\n"); + $total = 0; + $groupByDataType = []; + $typeOffset = strlen("Icinga\Module\Director\DataType\DataType"); + foreach ($this->getDatafieldsWithUnsupportedValuetype() as $varname => $datatype) { + $groupByDataType[$datatype][] = $varname; + $total++; + } + + foreach ($groupByDataType as $datatype => $datafields) { + printf("Data type: %s | count: %d\n", substr($datatype, $typeOffset), count($datafields)); + } + + if ($total > 0) { + printf( + "Total datafields that can not be migrated because of incompatible datatypes" + . " with new custom 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->getDatafieldsWithProtectedValues(), + $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); + } + + /** + * Check what datafields can not be migrated because they are protected + * + * @return void + */ + private function checkProtectedDatafields(): void + { + $count = count($this->getDatafieldsWithProtectedValues()); + + if ($count > 0) { + printf("The following number of datafields are protected and can not be migrated: %d\n\n", $count); + } + } + + /** + * Get query for datafields + * + * @return DbQuery + */ + private function getDataFieldQuery(): DbQuery + { + return $this->db()->select() + ->from( + ['dd' => 'director_datafield'], + [ + 'id' => 'dd.id', + 'varname' => 'dd.varname', + 'caption' => 'dd.caption', + 'description' => 'dd.description', + 'datatype' => 'dd.datatype', + 'category_id' => 'dd.category_id', + ] + ); + } + + /** + * Get datafields with unsupported value type in new custom variable support + * + * @return array + */ + private function getDatafieldsWithUnsupportedValuetype() + { + $query = $this->getDataFieldQuery(); + $query->addFilter(FilterAnd::matchAny( + FilterMatch::where('datatype', '*SqlQuery'), + FilterMatch::where('datatype', '*DirectorObject'), + FilterMatch::where('datatype', '*Dictionary') + )); + + $query->columns(['varname', 'datatype']); + + return $query->fetchPairs(); + } + + /** + * Get datafields with duplicate names + * + * @return array + */ + private function getDatafieldsWithDuplicateNames(): array + { + $query = $this->getDataFieldQuery(); + $query->columns(['varname' => 'dd.varname', 'count' => 'COUNT(dd.varname)']); + $query->group('dd.varname'); + $query->select()->having('COUNT(dd.varname) > 1'); + + return $query->fetchPairs(); + } + + /** + * Get datafields with protected values + * + * @return array + */ + private function getDatafieldsWithProtectedValues(): array + { + $query = $this->getDataFieldQuery(); + $query->joinLeft( + ['dds' => 'director_datafield_setting'], + "dd.id = dds.datafield_id AND dds.setting_name = 'visibility'", + [] + ); + $query->addFilter(Filter::matchAll( + FilterMatch::where('dd.datatype', '*String'), + FilterMatch::where('dds.setting_value', 'hidden') + ))->addFilter(Filter::fromQueryString('category_id IS NULL')); + + $query->columns(['varname']); + + return $query->fetchColumn(); + } + + /** + * Get datafields with categories + * + * @return array + */ + private function getDatafieldsWithCategory(): array + { + $query = $this->getDataFieldQuery(); + $query->addFilter(Filter::fromQueryString('category_id IS NOT NULL')); + $query->columns(['varname']); + + return $query->fetchColumn(); + } + + /** + * Migrate the binding of the datafield-to-object bindings + * + * @param int $datafieldId + * @param UuidInterface $propertyUuid + * + * @return void + */ + private function migrateDatafieldObjectTemplateBinding(int $datafieldId, UuidInterface $propertyUuid): void + { + $db = $this->db(); + $dbAdapter = $db->getDbAdapter(); + $objectTypes = ['host', 'service', 'notification', 'command', 'user']; + foreach ($objectTypes as $type) { + $query = $dbAdapter->select()->from(['io' => "icinga_{$type}"], ['*']) + ->join(['iof' => "icinga_{$type}_field"], "io.id = iof.{$type}_id", []) + ->where('iof.datafield_id = ?', $datafieldId); + + $objectInstance = DbObjectTypeRegistry::classByType($type); + $objects = $objectInstance::loadAll($db, $query); + foreach ($objects as $object) { + $db->insert( + "icinga_{$type}_property", + [ + 'property_uuid' => DbUtil::quoteBinaryCompat($propertyUuid->getBytes(), $dbAdapter), + "{$type}_uuid" => DbUtil::quoteBinaryCompat( + DbUtil::binaryResult($object->get('uuid')), + $dbAdapter + ) + ] + ); + } + } + } + + /** + * Delete the migrated datafields + * + * @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) + ); + } +} From d5aaa502dcd078607b3a49c29f89c061ddb7dee7 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 29 May 2026 14:22:47 +0200 Subject: [PATCH 006/221] Add test suite and documentation for custom variable and dictionary support Tests covering: - CustomVariable / CustomVariables: rendering, config string generation, dictionary and array type behaviour - CustomVariableDictionaryRendering: Icinga 2 DSL output for nested dicts - IcingaConfigHelper: macro validation and structured variable rendering - DirectorProperty: CRUD, inheritance, field assignment - IcingaHost / IcingaService / IcingaServiceApplyFor: object-level custom variable assignment and apply-for rule evaluation - BasketSnapshotCustomVariable: round-trip basket import/export - MigrateCommand: migration logic, --delete, --dry-run, skipped fields - CustomVariableForm / CustomVariablesForm / DeleteCustomVariableForm: form submission and validation - Rendered output fixtures updated for service5/6/7 and new host_dynamic_dict, service_apply_for_array, service_apply_for_dict Documentation: - Dictionary-Support-Changes.md: full feature overview and API reference - custom-variables-demo.sh: end-to-end shell demo script --- doc/New-Custom-Property-Implementation.md | 763 ++++++++++++++++++ .../CustomVariableDictionaryRenderingTest.php | 158 ++++ .../CustomVariable/CustomVariableTest.php | 325 ++++++++ .../CustomVariable/CustomVariablesTest.php | 3 + .../Director/Form/CustomVariableFormTest.php | 157 ++++ .../Director/Form/CustomVariablesFormTest.php | 53 ++ .../Form/DeleteCustomVariableFormTest.php | 186 +++++ .../Form/Lib/TestableCustomVariableForm.php | 31 + .../IcingaConfig/IcingaConfigHelperTest.php | 66 ++ .../BasketSnapshotCustomVariableTest.php | 311 +++++++ .../Director/Objects/DirectorPropertyTest.php | 382 +++++++++ .../Director/Objects/IcingaHostTest.php | 83 +- .../Objects/IcingaServiceApplyForTest.php | 309 +++++++ .../Director/Objects/IcingaServiceTest.php | 5 +- .../Objects/Lib/TestableMigrateCommand.php | 37 + .../Director/Objects/MigrateCommandTest.php | 507 ++++++++++++ .../Objects/rendered/host_dynamic_dict.out | 18 + .../Director/Objects/rendered/service5.out | 2 +- .../Director/Objects/rendered/service6.out | 4 +- .../Director/Objects/rendered/service7.out | 2 +- .../rendered/service_apply_for_array.out | 11 + .../rendered/service_apply_for_dict.out | 13 + 22 files changed, 3419 insertions(+), 7 deletions(-) create mode 100644 doc/New-Custom-Property-Implementation.md create mode 100644 test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php create mode 100644 test/php/library/Director/CustomVariable/CustomVariableTest.php create mode 100644 test/php/library/Director/Form/CustomVariableFormTest.php create mode 100644 test/php/library/Director/Form/CustomVariablesFormTest.php create mode 100644 test/php/library/Director/Form/DeleteCustomVariableFormTest.php create mode 100644 test/php/library/Director/Form/Lib/TestableCustomVariableForm.php create mode 100644 test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php create mode 100644 test/php/library/Director/Objects/DirectorPropertyTest.php create mode 100644 test/php/library/Director/Objects/IcingaServiceApplyForTest.php create mode 100644 test/php/library/Director/Objects/Lib/TestableMigrateCommand.php create mode 100644 test/php/library/Director/Objects/MigrateCommandTest.php create mode 100644 test/php/library/Director/Objects/rendered/host_dynamic_dict.out create mode 100644 test/php/library/Director/Objects/rendered/service_apply_for_array.out create mode 100644 test/php/library/Director/Objects/rendered/service_apply_for_dict.out diff --git a/doc/New-Custom-Property-Implementation.md b/doc/New-Custom-Property-Implementation.md new file mode 100644 index 000000000..2e8c4b000 --- /dev/null +++ b/doc/New-Custom-Property-Implementation.md @@ -0,0 +1,763 @@ +# New Custom Variable Support in Icinga Director + +## Overview + +A new **custom variable** support in Icinga Director has been implemented, with a focus on structured variable types (dictionaries and arrays). It extends the existing data-fields model with a new first-class `Property` concept that supports rich, nested types across all supported object types (host, service, command, user, notification), and brings them into configuration baskets, REST API, and apply-for rules + +--- + +## New Features + +### 1. Custom Variable Types + +A new `Custom Variables` section is available under the Icinga Director menu. Custom variables can be configured independently of data fields and support the following types: + +| Type | Description | +|-----------------------|-----------------------------------------------------------------------------------------------------------| +| `string` | Plain text value | +| `number` | Numeric value | +| `bool` | True/false value | +| `fixed-array` | Ordered list with a pre-defined structure; values assigned to preconfigured positions | +| `datalist-strict` | Only values from the chosen datalist can be assigned; can be stored as a single value or an array | +| `datalist-non-strict` | Values outside the chosen datalist are also accepted; can be stored as a single value or an array | +| `dynamic-array` | Uniform array where end-users can add values freely | +| `fixed-dictionary` | Key-value map with a fixed set of preconfigured keys | +| `dynamic-dictionary` | Key-value map where each key maps to a structured sub-dictionary; keys are added by end-users | + +> Only one level of nesting is allowed: an inner dictionary may contain non-dictionary values only. +> For `fixed-array`, all positions must be supplied on the object; none may be omitted. + +#### Type Examples (Infrastructure Monitoring Context) + +##### `string` +A plain text value. Useful for any single-value configuration parameter. + +``` +# On a host: the environment tag used to route alerts +vars.environment = "production" + +# On a service: the URL path to probe +vars.http_uri = "/api/health" + +# On a command: the path to the check plugin binary +vars.plugin_path = "/usr/lib/nagios/plugins/check_http" +``` + +##### `number` +A numeric value. Ideal for thresholds, timeouts, and retry counts. + +``` +# On a host: maximum check attempts before a hard state is raised +vars.max_check_attempts = 5 + +# On a service: SNMP polling interval in seconds +vars.snmp_timeout = 30 + +# On a notification: rate-limit delay in minutes between repeated alerts +vars.notification_interval = 60 +``` + +##### `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 +``` + +##### `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" +} +``` + +##### `dynamic-array` +A uniform array where end-users freely add values of the same type. Suitable for lists whose length varies per object. + +``` +# On a host: contact groups that should receive alerts for this host +vars.contact_groups = ["networking-ops", "on-call-primary", "noc"] + +# On a service: expected HTTP response strings (any of which satisfies the check) +vars.http_expect = ["HTTP/1.1 200", "HTTP/1.0 200"] + +# On a user: topics this user wants to receive notifications for +vars.notification_topics = ["disk", "cpu", "network"] +``` + +##### `dynamic-dictionary` +A dictionary where each top-level key is added freely by end-users, and the value for each key is a structured sub-dictionary with a preconfigured set of fields. Ideal for monitoring multiple similar resources on the same host (e.g. multiple disks, multiple virtual hosts). + +``` +# On a host: one entry per disk partition, each with threshold fields +vars.disk_checks += { + "/" = { + disk_partition = "/" + disk_wfree = "20%" + disk_cfree = "10%" + } + "/data" = { + disk_partition = "/data" + disk_wfree = "15%" + disk_cfree = "5%" + } +} + +# On a host: one entry per virtual host to probe via HTTP +vars.http_vhosts += { + "main-site" = { + http_address = "www.example.com" + http_uri = "/" + http_port = "443" + http_expect = ["HTTP/1.1 200"] + } + "api" = { + http_address = "api.example.com" + http_uri = "/health" + http_port = "443" + http_expect = ["HTTP/1.1 200", "HTTP/1.1 204"] + } +} +``` + +--- + +### 2. Enhanced Custom Variables UI + +- A dedicated **Custom Variables** tab is available on objects and templates for all supported object types (host, service, command, user, notification). +- Users can click **Add Custom Variable** to attach configured custom variables to a template. +- Inherited custom variables from parent templates are shown and editable on objects importing those templates. +- Dynamic dictionary items are **appended** (using `+=`) instead of overwritten, so values from multiple templates are merged on the final object. + +--- + +### 3. Apply-For Rule Support for Dictionaries and Arrays + +- Apply-for rules now support **dynamic arrays** and **dynamic dictionaries**, not just flat arrays. +- Service apply rules can reference dictionary items from the host via `$value.$` syntax. +- The `config` keyword in apply-for rules has been renamed to `value` for clarity. +- The IcingaService form shows nested dictionary key suggestions as a list for use in apply-for configuration. + +> Apply-for rules resolve dictionary keys from the **host's** custom variable definitions. The variable referenced in the apply-for field must be defined as a custom property on a host template. + +#### Example: Apply-For using a Dynamic Array (`http_vhosts_list`) + +**Scenario:** A host has a `dynamic-array` variable `http_vhosts_list` listing the virtual host addresses to probe. A service apply rule creates one HTTP check per entry. + +**Host variable (on `web-server-01`):** +``` +vars.http_vhosts_list = [ + "www.example.com", + "api.example.com", + "status.example.com" +] +``` + +**Service apply rule (configured in Icinga Director):** + +- **Apply for:** `http_vhosts_list` (the array variable on the host) +- **Service name pattern:** `http - $item$` +- **check_command:** `http` + +**Generated Icinga 2 config:** +``` +apply Service "http - " for (item in host.vars.http_vhosts_list) { + check_command = "http" + vars.http_address = item + vars.http_port = 443 + vars.http_uri = "/" + assign where host.vars.http_vhosts_list +} +``` + +**Result:** Three services are created on `web-server-01`: +- `http - www.example.com` → checks `www.example.com` +- `http - api.example.com` → checks `api.example.com` +- `http - status.example.com` → checks `status.example.com` + +--- + +#### Example: Apply-For using a Dynamic Dictionary (`disk_checks`) + +**Scenario:** A host has a `dynamic-dictionary` variable `disk_checks` where each key is a disk label and the value is a structured sub-dictionary with threshold fields. A service apply rule creates one disk check per entry. + +**Host variable (on `linux-server-01`, merged from templates):** +``` +vars.disk_checks += { + "root" = { + disk_partition = "/" + disk_wfree = "20%" + disk_cfree = "10%" + } + "data" = { + disk_partition = "/data" + disk_wfree = "15%" + disk_cfree = "5%" + } + "backup" = { + disk_partition = "/mnt/backup" + disk_wfree = "10%" + disk_cfree = "5%" + } +} +``` + +**Service apply rule (configured in Icinga Director):** + +- **Apply for:** `disk_checks` (the dictionary variable on the host) +- **Service name pattern:** `disk - $key$` +- **check_command:** `disk` +- **Custom variables** referencing the sub-dictionary fields via `$value.$`: + +| Variable | Value | +|----------|-------| +| `disk_partitions` | `$value.disk_partition$` | +| `disk_wfree` | `$value.disk_wfree$` | +| `disk_cfree` | `$value.disk_cfree$` | + +> The hint text below the Custom Variables section in the apply-rule form shows which `$value.*$` fields are available for the selected dictionary. + +**Generated Icinga 2 config:** +``` +apply Service "disk - " for (key => value in host.vars.disk_checks) { + check_command = "disk" + vars.disk_partitions = value.disk_partition + vars.disk_wfree = value.disk_wfree + vars.disk_cfree = value.disk_cfree + assign where host.vars.disk_checks +} +``` + +**Result:** Three services are created on `linux-server-01`: +- `disk - root` → checks `/` with warn=20%, crit=10% +- `disk - data` → checks `/data` with warn=15%, crit=5% +- `disk - backup` → checks `/mnt/backup` with warn=10%, crit=5% + +Because `disk_checks` uses `+=`, a child template or the host itself can add more partitions without overwriting entries from the parent template. All entries from every level of the template tree are merged into the final host config. + +--- + +### 4. REST API Endpoint for Updating Object Custom Variables + +A new REST API endpoint allows updating custom variables for an object directly: + +``` +PUT /director//variables? +``` + +| Object type | Query parameters | +|-------------|-----------------| +| Host | `name=` | +| Individual Service | `host=&name=` | +| Applied Service or Service Template | `name=` | +| User | `name=` | +| Notification | `name=` | +| Command | `name=` | + +The request body is a JSON object whose keys are variable names and values are the variable values. All standard types are accepted: strings, numbers, booleans, arrays, and nested dictionaries. + +> The custom variable must already be configured in the `director_property` table before it can be updated via the REST API. Attempting to update an unconfigured variable will return a `404 Not Found` error. + +--- + +#### Host — `linux-server-01` + +Updates disk check thresholds (dynamic-dictionary) and contact groups (dynamic-array): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/host/variables?name=linux-server-01' \ + -d '{ + "disk_checks": { + "root": { + "disk_partition": "/", + "disk_wfree": "20%", + "disk_cfree": "10%" + }, + "data": { + "disk_partition": "/data", + "disk_wfree": "15%", + "disk_cfree": "5%" + } + }, + "contact_groups": ["noc", "linux-ops"], + "environment": "production" + }' +``` + +--- + +#### Individual Service — `linux-server-01` / `http - www.example.com` + +Updates the HTTP check parameters (string, number, bool, dynamic-array): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/service/variables?host=linux-server-01&name=http%20-%20www.example.com' \ + -d '{ + "http_address": "www.example.com", + "http_port": 443, + "http_uri": "/health", + "ssl_verify": true, + "http_expect": ["HTTP/1.1 200", "HTTP/1.0 200"] + }' +``` + +--- + +#### Service Template — `generic-http-service` + +Updates default SNMP v3 credentials on a service template (fixed-dictionary): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/service/variables?name=generic-http-service' \ + -d '{ + "snmp_v3": { + "username": "monitoring", + "auth_protocol": "SHA", + "auth_password": "newAuthPass!", + "priv_protocol": "AES", + "priv_password": "newPrivPass!" + }, + "snmp_timeout": 30 + }' +``` + +--- + +#### User — `on-call-engineer` + +Updates notification preferences (string, bool, dynamic-array, fixed-dictionary): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/user/variables?name=on-call-engineer' \ + -d '{ + "pagerduty_key": "abc123def456", + "phone": "+1-555-0199", + "notify_on_recovery": true, + "notify_on_flapping": false, + "subscribed_services": ["disk", "http", "cpu", "memory"], + "working_hours": { + "timezone": "Europe/Berlin", + "start_time": "08:00", + "end_time": "18:00" + } + }' +``` + +--- + +#### Notification — `slack-host-notification` + +Updates Slack webhook details and escalation recipients (string, dynamic-array, fixed-dictionary): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/notification/variables?name=slack-host-notification' \ + -d '{ + "slack_webhook_url": "https://hooks.slack.com/services/T.../B.../newtoken", + "slack_channel": "#alerts-production", + "include_graphs": true, + "escalation_emails": ["noc@example.com", "oncall@example.com"], + "pagerduty": { + "integration_key": "xyz789", + "severity": "critical", + "component": "infrastructure", + "group": "platform" + } + }' +``` + +--- + +#### Command — `check_by_ssh` + +Updates default SSH connection parameters and plugin flags (string, number, bool, fixed-array): + +```bash +curl -k -u 'icingaadmin:icinga' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -X PUT 'http://localhost/icingaweb2/director/command/variables?name=check_by_ssh' \ + -d '{ + "by_ssh_logname": "monitoring", + "by_ssh_port": 22, + "by_ssh_quiet": false, + "by_ssh_arguments": ["-w", "20", "-c", "10"] + }' +``` + +--- + +### 5. Configuration Basket Support + +- Configuration baskets now include custom variables when snapshotting **templates**. +- The snapshot captures both the `Template` (with `properties` UUIDs) and a `CustomVariable` section containing the full property definitions and their nested items. +- Restoring a basket snapshot restores the associated custom variable schemas alongside the template. + +--- + +### 6. New `DirectorProperty` Object + +A new database-backed object `DirectorProperty` (`library/Director/Objects/DirectorProperty.php`) stores the custom variable schema: + +- UUID-based identity +- Hierarchical structure via `parent_uuid` (used for nested dictionary fields and array items) +- Supports all value types listed above +- Linked to templates via per-object-type join tables (`icinga__property`) + +--- + +## Database Changes + +A new migration (`schema/mysql-migrations/upgrade_193.sql`, `schema/pgsql-migrations/upgrade_193.sql`) and updated full schemas introduce: + +- `director_property` — stores custom variable definitions (`uuid`, `key_name`, `value_type`, `label`, `description`, `parent_uuid`) +- `director_property_datalist` — links datalist-typed properties to a Director datalist +- `icinga_host_property` — host-to-property join table +- `icinga_service_property` — service-to-property join table +- `icinga_command_property` — command-to-property join table +- `icinga_notification_property` — notification-to-property join table +- `icinga_user_property` — user-to-property join table +- `property_uuid` column added to all `icinga_*_var` tables to track which property a stored value belongs to + +--- + +### Datafield Migration + +Only fields that meet **all** of the following criteria are migrated: + +- Data type is one of: `String`, `Number`, `Boolean`, `Array`, `Datalist` +- The field has **no category** (`category_id IS NULL`) +- The field has **no duplicate variable name** (only one field per `varname`) +- The field is **not protected** (`visibility != 'hidden'`) +- No custom variable with the same `key_name` already exists + +Fields that do not meet these criteria are skipped and reported when `--verbose` is used. + +#### Data type mapping + +| Data field type | Custom variable `value_type` | +|-----------------|------------------------------| +| `DataTypeString` | `string` | +| `DataTypeNumber` | `number` | +| `DataTypeBoolean` | `bool` | +| `DataTypeArray` | `dynamic-array` (with a `string` child item) | +| `DataTypeDatalist` (strict / suggest\_strict) | `datalist-strict` | +| `DataTypeDatalist` (other) | `datalist-non-strict` | + +After creating the custom variable configurations, existing template bindings (host, service, notification, command, user) are carried over to the new `icinga__property` join tables. + +#### Usage + +```bash +# Preview what would be migrated — no DB changes are made +icingacli director migrate datafields --dry-run + +# Preview with per-field detail +icingacli director migrate datafields --dry-run --verbose + +# Run the migration +icingacli director migrate datafields + +# Run with per-field progress output +icingacli director migrate datafields --verbose + +# Remove migrated datafields after migration +icingacli director migrate datafields --delete +``` + +#### Example dry-run output + +Suppose the Director instance has eight data fields. Two are duplicates, one belongs to a category, one is a hidden/protected string, one has a type that cannot be mapped (`DataTypeSqlQuery`), and the remaining three are plain `String`, `Number`, and `Array` fields ready to migrate. + +``` +$ icingacli director migrate datafields --dry-run --verbose + +The following datafield types and the corresponding number of datafields can be migrated: +Data type: String | count: 1 +Data type: Number | count: 1 +Data type: Array | count: 1 +Total datafields that can be migrated: 3 + +The following datafields can not be migrated as there are duplicates: +Var name: environment | count: 2 +Total datafields that can not be migrated because of having duplicates: 2 + +The following number of datafields belong to a category and can not be migrated: 1 + +The following number of datafields are protected and can not be migrated: 1 + +The following datafield types and the corresponding number of datafields can not be migrated: +Data type: SqlQuery | count: 1 +Total datafields that can not be migrated because of incompatible datatypes with new custom property support: 1 + +Number of datafields that can not be migrated as the custom properties with the same name already exists: 0 +Migrating Data fields +Migration completed +Summary: +Total datafields migrated: 0 +Total datafields skipped: 5 +``` + +> `--dry-run` prints the summary but does **not** write anything to the database. + +#### Example live migration output + +Running without `--dry-run` performs the migration inside a single transaction: + +``` +$ icingacli director migrate datafields --verbose + +Migrating Data fields +[-] Skipping migrating datafield 'environment' as there are '2' datafields with same name +[-] Skipping migrating datafield 'category_field' as it belongs to a category +[-] Skipping migrating datafield 'secret_password' as it is protected +[-] Skipping migration of datafield 'custom_sql_query' as it has an unsupported datatype 'SqlQuery' +[+] Datafield 'max_check_attempts' successfully migrated +[+] Datafield 'agent_enabled' successfully migrated +[+] Datafield 'contact_groups' successfully migrated +Migration completed +Summary: +Total datafields migrated: 3 +Total datafields skipped: 5 +``` + +After a successful run, the three fields appear as custom variables in `director_property` and their template assignments are reflected in the `icinga__property` tables. + +--- + +## Custom Variables by Object Type + +The following examples show how the new custom variable types can be applied across each supported Icinga 2 object type. + +### Host + +Hosts are the primary target for the new system. All variable types are fully supported, and the dynamic-dictionary merge behaviour is most relevant here (values from multiple imported templates are combined). + +``` +# generic-linux-host template +vars.environment = "production" # string +vars.max_check_retries = 3 # number +vars.agent_enabled = true # bool +vars.ssh_args = ["monitoring", "22"] # fixed-array +vars.contact_groups = ["noc", "linux-ops"] # dynamic-array + +# Per-disk monitoring (dynamic-dictionary, merged across templates) +vars.disk_checks += { + "/" = { + disk_partition = "/" + disk_wfree = "20%" + disk_cfree = "10%" + } +} + +# MySQL credentials for the DB host template (fixed-dictionary) +vars.mysql_conn = { + host = "localhost" + port = "3306" + user = "icinga" + password = "secret" + database = "prod" +} +``` + +--- + +### Service + +Service custom variables drive check plugin arguments. Fixed types work well for connection parameters; dynamic arrays capture lists of expected strings; `string` and `number` types cover thresholds. + +``` +# generic-http-service template +vars.http_address = "www.example.com" # string +vars.http_port = 443 # number +vars.ssl_verify = true # bool +vars.http_expect = ["HTTP/1.1 200"] # dynamic-array + +# HTTP virtual-host probes assigned per service (dynamic-dictionary) +vars.http_vhosts += { + "main" = { + http_address = "www.example.com" + http_uri = "/" + http_port = "443" + } +} + +# SNMP v3 credentials for an SNMP service (fixed-dictionary) +vars.snmp_v3 = { + username = "monitoring" + auth_protocol = "SHA" + auth_password = "authpass" + priv_protocol = "AES" + priv_password = "privpass" +} + +# SSH-based check arguments (fixed-array: [user, port, identity-file]) +vars.ssh_args = ["monitoring", "22", "/etc/icinga2/ssh/id_rsa"] +``` + +**Apply-for rule:** A service apply rule iterates over `host.vars.disk_checks` and maps each disk entry to a service. Dictionary fields are referenced as `$value.disk_partition$`, `$value.disk_wfree$`, etc. + +--- + +### User + +User objects benefit from `string` (contact details), `bool` (opt-in flags), `dynamic-array` (subscribed topics), and `datalist-strict` (controlled notification preferences). + +``` +# on-call-engineer user +vars.pagerduty_key = "abc123def456" # string — PagerDuty integration key +vars.phone = "+1-555-0100" # string — SMS fallback number +vars.notify_on_recovery = true # bool — send recovery notifications +vars.notify_on_flapping = false # bool — suppress flapping alerts + +# Services this user wants to receive notifications for (dynamic-array) +vars.subscribed_services = ["disk", "http", "cpu", "memory"] + +# Preferred notification channels, from a "channels" datalist (datalist-strict) +vars.preferred_channel = "slack" + +# Working hours window (fixed-dictionary) +vars.working_hours = { + timezone = "Europe/Berlin" + start_time = "08:00" + end_time = "18:00" +} +``` + +--- + +### Command + +Command objects use custom variables to parameterise plugin invocations. `string` and `number` types set default argument values; `bool` toggles flags; `fixed-array` passes positional arguments; `fixed-dictionary` groups related plugin options. + +``` +# check_by_ssh command +vars.by_ssh_logname = "monitoring" # string — SSH user +vars.by_ssh_port = 22 # number — SSH port +vars.by_ssh_quiet = false # bool — suppress SSH banner + +# Positional arguments passed to the remote plugin (fixed-array) +vars.by_ssh_arguments = ["-w", "20", "-c", "10"] + +# SSL/TLS options for check_http (fixed-dictionary) +vars.http_ssl_opts = { + ssl_cert = "/etc/ssl/certs/ca-bundle.crt" + ssl_verify = "yes" + min_tls = "1.2" +} + +# Environments this command is valid in (datalist-strict, dynamic-array) +vars.valid_environments = ["production", "staging"] +``` + +--- + +### Notification + +Notification objects use custom variables to drive alert routing, templating, and channel selection. All scalar types apply; `fixed-dictionary` is useful for channel-specific config blocks; `dynamic-array` lists escalation recipients. + +``` +# slack-notification notification object +vars.slack_webhook_url = "https://hooks.slack.com/services/T.../B.../xxx" # string +vars.slack_channel = "#alerts-production" # string +vars.notification_icon = ":fire:" # string +vars.include_graphs = true # bool +vars.retry_count = 3 # number + +# Escalation recipients (dynamic-array) +vars.escalation_emails = ["noc@example.com", "oncall@example.com"] + +# PagerDuty routing block (fixed-dictionary) +vars.pagerduty = { + integration_key = "abc123" + severity = "critical" + component = "infrastructure" + group = "platform" +} + +# Escalation tier, from a "severity-levels" datalist (datalist-strict) +vars.escalation_tier = "P1" +``` + +--- + +## Known Limitations / Not Yet Implemented + +- **No visibility control** — custom variable values (e.g. passwords) are always visible; no masking support. +- **Apply-for rules** only work with `dynamic-array` and `dynamic-dictionary` types. diff --git a/test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php b/test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php new file mode 100644 index 000000000..d2a98755d --- /dev/null +++ b/test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php @@ -0,0 +1,158 @@ + +// 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 testDynamicDictionaryRendersWithEqualsWhenNotOverride(): void + { + $vars = new CustomVariables(); + $vars->disk_checks = ['warn' => '20%', 'crit' => '10%']; + // no setOverrideKeyName call — ordinary assignment + + $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) + ); + } + + public function testDatalistValueRendersAsString(): void + { + $vars = new CustomVariables(); + // A var whose value comes from a datalist is stored as a plain string; the + // datalist constraint is a UI concern only, not reflected in config rendering + $vars->env = 'production'; + + $this->assertEquals( + $this->indent . 'vars.env = "production"' . "\n", + $vars->toConfigString() + ); + } +} diff --git a/test/php/library/Director/CustomVariable/CustomVariableTest.php b/test/php/library/Director/CustomVariable/CustomVariableTest.php new file mode 100644 index 000000000..8c2fe65fc --- /dev/null +++ b/test/php/library/Director/CustomVariable/CustomVariableTest.php @@ -0,0 +1,325 @@ + +// 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('k', null)); + } + + public function testCreateBoolTrueReturnsBoolean(): void + { + $this->assertInstanceOf(CustomVariableBoolean::class, CustomVariable::create('k', true)); + } + + public function testCreateBoolFalseReturnsBoolean(): void + { + $this->assertInstanceOf(CustomVariableBoolean::class, CustomVariable::create('k', false)); + } + + public function testCreateIntegerReturnsNumber(): void + { + $this->assertInstanceOf(CustomVariableNumber::class, CustomVariable::create('k', 42)); + } + + public function testCreateFloatReturnsNumber(): void + { + $this->assertInstanceOf(CustomVariableNumber::class, CustomVariable::create('k', 3.14)); + } + + public function testCreateStringReturnsString(): void + { + $this->assertInstanceOf(CustomVariableString::class, CustomVariable::create('k', 'hello')); + } + + public function testCreateIndexedArrayReturnsArray(): void + { + $this->assertInstanceOf(CustomVariableArray::class, CustomVariable::create('k', ['a', 'b', 'c'])); + } + + public function testCreateEmptyArrayReturnsArray(): void + { + $this->assertInstanceOf(CustomVariableArray::class, CustomVariable::create('k', [])); + } + + public function testCreateNumericStringKeyedArrayReturnsArray(): void + { + // Arrays whose keys are numeric strings ('0', '1', …) are treated as arrays, not dictionaries + $this->assertInstanceOf(CustomVariableArray::class, CustomVariable::create('k', ['0' => 'x', '1' => 'y'])); + } + + public function testCreateAssociativeArrayReturnsDictionary(): void + { + $this->assertInstanceOf(CustomVariableDictionary::class, CustomVariable::create('k', ['key' => 'val'])); + } + + public function testCreateMixedKeyArrayReturnsDictionary(): void + { + // Mixed numeric and string keys → dictionary because at least one key is non-integer + $this->assertInstanceOf( + CustomVariableDictionary::class, + CustomVariable::create('k', [0 => 'a', 'label' => 'b']) + ); + } + + public function testCreateObjectReturnsDictionary(): void + { + $obj = (object) ['warn' => '20%', 'crit' => '10%']; + $this->assertInstanceOf(CustomVariableDictionary::class, CustomVariable::create('k', $obj)); + } + + public function testCreatePreservesKey(): void + { + $var = CustomVariable::create('my_key', 'value'); + $this->assertEquals('my_key', $var->getKey()); + } + + public function testCreatePreservesValue(): void + { + $var = CustomVariable::create('k', 'hello'); + $this->assertEquals('hello', $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' => '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' => 'data', + 'varvalue' => 'abc', + ]); + } + + 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 testDictionaryEqualsItself(): void + { + $dict = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); + assert($dict instanceof CustomVariableDictionary); + $this->assertTrue($dict->equals($dict)); + } + + public function testEqualDictionariesAreEqual(): void + { + $a = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); + $b = CustomVariable::create('k', ['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('k', ['crit' => '10%', 'warn' => '20%']); + $b = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); + $this->assertTrue($a->equals($b)); + } + + public function testDictionariesWithDifferentKeysAreNotEqual(): void + { + $a = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); + $b = CustomVariable::create('k', ['warn' => '20%']); + $this->assertFalse($a->equals($b)); + } + + public function testDictionariesWithSameKeysDifferentValuesAreNotEqual(): void + { + $a = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); + $b = CustomVariable::create('k', ['warn' => '30%', 'crit' => '10%']); + $this->assertFalse($a->equals($b)); + } + + public function testDictionaryIsNotEqualToString(): void + { + $dict = CustomVariable::create('k', ['warn' => '20%']); + $str = CustomVariable::create('k', 'hello'); + $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('k', []); + $b = CustomVariable::create('k', []); + $this->assertTrue($a->equals($b)); + } +} 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/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php new file mode 100644 index 000000000..386385064 --- /dev/null +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -0,0 +1,157 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form; + +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Test\BaseTestCase; +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 = []; + + 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 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 tearDown(): void + { + if ($this->hasDb()) { + $dba = $this->getDb()->getDbAdapter(); + foreach ($this->createdKeyNames as $keyName) { + $rows = $dba->fetchAll( + $dba->select() + ->from('director_property', ['uuid']) + ->where('key_name = ?', $keyName) + ); + 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 = ?', $keyName)); + } + } + } + + parent::tearDown(); + } +} diff --git a/test/php/library/Director/Form/CustomVariablesFormTest.php b/test/php/library/Director/Form/CustomVariablesFormTest.php new file mode 100644 index 000000000..c9bbc93d9 --- /dev/null +++ b/test/php/library/Director/Form/CustomVariablesFormTest.php @@ -0,0 +1,53 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form; + +use Icinga\Module\Director\Forms\CustomVariablesForm; +use Icinga\Module\Director\Test\BaseTestCase; + +class CustomVariablesFormTest extends BaseTestCase +{ + 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 testFiltersIntegerZero(): void + { + $result = CustomVariablesForm::filterEmpty(['retry_count' => 0, 'max_check_attempts' => 3]); + $this->assertSame(['max_check_attempts' => 3], $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([])); + } +} diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php new file mode 100644 index 000000000..6c5c89b08 --- /dev/null +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -0,0 +1,186 @@ + +// 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\DirectorDatalist; +use Icinga\Module\Director\Objects\DirectorProperty; +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 tearDown(): void + { + if ($this->hasDb()) { + $dba = $this->getDb()->getDbAdapter(); + foreach (['___TEST___environment', '___TEST___snmp_v3', '___TEST___escalation_tier'] as $keyName) { + $rows = $dba->fetchAll( + $dba->select() + ->from('director_property', ['uuid']) + ->where('key_name = ?', $keyName) + ); + 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 = ?', $keyName)); + } + } + + $dba->delete('director_datalist', ['list_name = ?' => '___TEST___severity_levels']); + } + + parent::tearDown(); + } +} 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/IcingaConfig/IcingaConfigHelperTest.php b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php index 10cd644cb..4500479a6 100644 --- a/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php +++ b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php @@ -126,4 +126,70 @@ public function testRenderStringWithVariablesX() c::renderStringWithVariables('\tI am\rrendering\nproperly\fand I $support$ "multiple" $variables$\$') ); } + + 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 testIsValidMacroNameNullWhitelistBehavesLikeNoWhitelist() + { + // Passing null explicitly is the same as omitting the argument + $this->assertTrue(c::isValidMacroName('host.vars.custom', null)); + $this->assertFalse(c::isValidMacroName('a', null)); + $this->assertFalse(c::isValidMacroName('value.', null)); + } + + 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.custom', ['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', ['other.field'])); + } + + 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.'])); + } } diff --git a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php new file mode 100644 index 000000000..87f310753 --- /dev/null +++ b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php @@ -0,0 +1,311 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\DirectorObject\Automation\BasketSnapshot; +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 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 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 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..7ae27e922 --- /dev/null +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -0,0 +1,382 @@ + +// 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 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 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); + } + + public function testDynamicDictionaryNestingIsOneLevelOnly(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $parent = $this->makeProperty('disk_checks', 'dynamic-dictionary', 'Disk Checks', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + foreach (['mount_point', '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); + foreach ($reloaded->fetchItemsFromDb() as $child) { + $this->assertNotEquals( + 'dynamic-dictionary', + $child->get('value_type'), + "Child of dynamic-dictionary must not itself be dynamic-dictionary (one-level nesting rule)" + ); + } + } + + 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 testDatalistNonStrictAssociatesDatalist(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $listName = self::PREFIX . 'env_suggest'; + $this->makeDatalist($listName, $db)->store(); + $property = $this->importPropertyWithDatalist( + 'env_suggest', + 'datalist-non-strict', + 'Env Suggest', + $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 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')); + } + + 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 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) { + $quotedUuid = DbUtil::quoteBinaryCompat(DbUtil::binaryResult($row->uuid), $dba); + $dba->delete('director_property', $dba->quoteInto('parent_uuid = ?', $quotedUuid)); + $dba->delete('director_property_datalist', $dba->quoteInto('property_uuid = ?', $quotedUuid)); + } + $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(); + } + + 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); + } + + /** + * 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..247ef8173 --- /dev/null +++ b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php @@ -0,0 +1,309 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Db\DbUtil; +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 testValueFieldMacroAllowedInVars(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + // 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(); + + $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 testUnknownValueFieldStrippedFromVars(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + // Create disk_checks (dynamic-dictionary) with NO children — so value.not_a_real_field won't be whitelisted + $parent = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => self::PREFIX . 'disk_checks_strip', + 'value_type' => 'dynamic-dictionary', + 'label' => 'Disk Checks Strip', + ], $db); + $parent->store(); + $this->createdPropertyKeys[] = self::PREFIX . 'disk_checks_strip'; + + $service = $this->applyService('disk-strip-check', 'host.vars.' . self::PREFIX . 'disk_checks_strip'); + $service->setConnection($db); + $service->{'vars.secret'} = '$value.not_a_real_field$'; + + $rendered = (string) $service; + + $this->assertStringContainsString( + 'vars.secret = "$value.not_a_real_field$"', + $rendered, + 'Non-whitelisted macro must render as quoted string' + ); + } + + 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..9234d0b4e 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 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..d2e7debab --- /dev/null +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -0,0 +1,507 @@ + +// 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\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'; + + // Non-migratable datafield varnames + private const VAR_SQL_QUERY = self::PREFIX . 'sql_query_field'; + + private const VAR_CATEGORIZED = self::PREFIX . 'categorized_field'; + + private const VAR_HIDDEN = self::PREFIX . 'hidden_field'; + + private const VAR_DUP = self::PREFIX . 'dup_field'; + + private const LIST_NAME = self::PREFIX . 'migrate_list'; + + private const CAT_NAME = self::PREFIX . 'migrate_category'; + + private const MIGRATABLE = [ + self::VAR_ENV, + self::VAR_HTTP_VHOSTS, + self::VAR_CHECK_INTERVAL, + self::VAR_ENV_CHOICES, + self::VAR_ENV_SUGGEST, + ]; + + 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_SQL_QUERY, + self::VAR_CATEGORIZED, + self::VAR_HIDDEN, + self::VAR_DUP, + ]; + + 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 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 testProtectedStringFieldIsSkipped(): 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_HIDDEN) + ); + $this->assertEquals(0, (int) $count, 'Protected (hidden visibility) datafield must not be migrated'); + } + + 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 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'); + } + + protected function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $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(); + + // 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. hidden_field — protected string (visibility=hidden, skip) + $field = DirectorDatafield::create([ + 'varname' => self::VAR_HIDDEN, + 'caption' => 'Hidden Field', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', + ], $db); + $field->set('visibility', 'hidden'); + $field->store(); + + // 9. dup_field × 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' => 'Dup A', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', + ]); + $dba->insert('director_datafield', [ + 'uuid' => DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $dba), + 'varname' => self::VAR_DUP, + 'caption' => 'Dup B', + 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', + ]); + } + + 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..f30059353 --- /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.overridenVar = "___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..7f1ff8cda --- /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.overridenVar = "___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 +} + From 669a6561556a578eaf56f8c5c87109ffe97936ef Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 2 Jun 2026 10:45:20 +0200 Subject: [PATCH 007/221] CustomVariablesForm: Allow saving empty items for dynamic dictionaries --- application/forms/CustomVariablesForm.php | 24 +++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index 5fc1200a5..3b9678414 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -280,7 +280,6 @@ public static function filterEmpty(array $array): array return array_filter( array_map(function ($item) { if (! is_array($item)) { - // Recursively clean nested arrays return $item; } @@ -320,10 +319,23 @@ protected function onSuccess(): void $value = $values[$key] ?? null; if (is_array($value)) { - $filteredValue = self::filterEmpty($value); - // Store the fixed array as empty only if the filtered array is empty - if ($property['value_type'] !== 'fixed-array' || empty($filteredValue)) { - $value = $filteredValue; + if ($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 { + $filteredValue = self::filterEmpty($value); + // Store the fixed array as empty only if the filtered array is empty + if ($property['value_type'] !== 'fixed-array' || empty($filteredValue)) { + $value = $filteredValue; + } } } @@ -338,7 +350,7 @@ protected function onSuccess(): void ); } - if (! is_bool($value) && empty($value)) { + if ($property['value_type'] !== 'dynamic-dictionary' && ! is_bool($value) && empty($value)) { $vars->set($key, null); } else { $vars->set($key, $value); From 225cde82c25ba6321d921736b86c29d437ccfcc6 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 2 Jun 2026 10:47:37 +0200 Subject: [PATCH 008/221] IcingaService: Whitelist 'key' for apply-for rule --- library/Director/Objects/IcingaService.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index deee963c7..e4bfe8808 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -368,7 +368,7 @@ protected function renderObjectHeader() } $propertyType = $this->fetchApplyForPropertyType($applyForVar); - $isApplyFor = in_array($propertyType, ['fixed-dictionary', 'dynamic-dictionary'], true); + $isApplyFor = $propertyType === 'dynamic-dictionary'; $varName = '"' . $name . '"'; if (c::stringHasMacro($name)) { @@ -693,7 +693,13 @@ public function vars() $result = $this->db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + $propertyType = $this->fetchApplyForPropertyType($applyFor); + $isApplyFor = $propertyType === 'dynamic-dictionary'; $whiteList = ['value', 'host.*', 'value[*]', 'value[*].*']; + if ($isApplyFor) { + $whiteList[] = 'key'; + } + foreach ($result as $row) { if (str_contains($row['key_name'], ' ')) { continue; From 7c39d2872df2f4f3d8226d59b260a0117375dcc0 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 2 Jun 2026 10:58:03 +0200 Subject: [PATCH 009/221] ObjectController: Modify apply for header to support accessing of keys --- .../Web/Controller/ObjectController.php | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index 34c061ebf..b736c7062 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -676,28 +676,45 @@ private function prepareApplyForHeader(): void return; } - $this->content()->addHtml(new HtmlElement( + $applyForHeader = new HtmlElement('div', Attributes::create(['class' => ['apply-for-header']])); + $applyForHeaderContent = HtmlElement::create( 'div', - Attributes::create(['class' => ['apply-for-header']]), - HtmlElement::create( - 'div', - Attributes::create(['class' => ['apply-for-header-content']]), - [ - Text::create(sprintf( - $this->translate( - 'The values of selected host variable for apply-for-rule' - . ' is accessible through %s.' - ), - '$value$' - )) - ] - ) - )); + 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' + . ' is 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; From b83233d7fff8e462cff2d1c6165b1def18c93009 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 8 Jun 2026 14:26:14 +0200 Subject: [PATCH 010/221] Add confirmation feature for renaming a custom variable --- .../controllers/CustomvarController.php | 12 +++- application/forms/CustomVariableForm.php | 72 ++++++++++++++++--- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/application/controllers/CustomvarController.php b/application/controllers/CustomvarController.php index 3798a2afb..0928bb94e 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -105,14 +105,24 @@ public function indexAction(): void $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) { + if ( + (int) $form->getValue('used') > 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'), - $form->getValue('key_name') + $keyName )); $this->sendExtraUpdates(['#col1']); diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index 7538f4bdd..a34c3a14a 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -6,6 +6,7 @@ use Icinga\Data\Filter\FilterException; use Icinga\Module\Director\Data\Db\DbConnection; use Icinga\Module\Director\Db; +use Icinga\Module\Director\Web\Form\Element\IplBoolean; use Icinga\Web\Session; use ipl\I18n\Translation; use ipl\Web\Common\CsrfCounterMeasure; @@ -29,6 +30,9 @@ class CustomVariableForm extends CompatForm /** @var bool Whether the field is a nested field or not */ private $isNestedField = false; + /** @var ?string The key name as stored in the database, used to detect pending renames */ + private ?string $storedKeyName = null; + public function __construct( protected DbConnection $db, protected ?UuidInterface $uuid = null, @@ -86,11 +90,42 @@ public function setIsNestedField(bool $isNestedField): self 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->addElement($this->createCsrfCounterMeasure(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(); @@ -101,19 +136,15 @@ protected function assemble(): void $this->addElement( 'hidden', 'key_name', - [ - 'label' => $this->translate('Property Key *'), - 'required' => true, - 'value' => $db->fetchOne($query) - ] + ['value' => $db->fetchOne($query)] ); } else { $this->addElement( 'text', 'key_name', [ - 'label' => $this->translate('Property Key *'), - 'required' => true + 'label' => $this->translate('Property Key *'), + 'required' => true ] ); } @@ -241,21 +272,34 @@ protected function assemble(): void ->setAttribute( 'title', $this->translate( - 'This property is used in one or more templates and hence the item type cannot be changed.' + 'This property is used in one or more templates' + . ' and hence the item type cannot be changed.' ) ) ->setAttribute('disabled', true); } } + if ($pendingRename) { + $this->addElement(new IplBoolean('confirm_rename_change', [ + 'label' => $this->translate('Confirm rename'), + 'required' => true + ])); + + $this->getElement('confirm_rename_change')->addMessage( + '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) { - // TODO: Ask for confirmation before deleting $this->getElement('submit') - ->getWrapper() + ->getWrapper() ->prepend( (new ButtonLink( $this->translate('Delete'), @@ -390,6 +434,14 @@ protected function onSuccess(): void 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(); if ($this->uuid === null) { $this->addNewProperty($values, $datalist, $itemType); From 56230e43bf1b33546dfdf4e5cdc707b8b639b94a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 16 Jun 2026 11:04:27 +0200 Subject: [PATCH 011/221] CustomvarController: Remove the unnecessary autorefreshInterval settings --- application/controllers/CustomvarController.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/application/controllers/CustomvarController.php b/application/controllers/CustomvarController.php index 0928bb94e..ef94f0a81 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -203,7 +203,6 @@ public function indexAction(): void $this->setTitle($title); $this->setTitleTab('customvar'); - $this->setAutorefreshInterval(10); } public function usageAction(): void @@ -231,7 +230,6 @@ function (ListItem $item, $data) use (&$objectClass, &$usageList) { $this->setTitle($this->translate('Custom Variable Usage')); $this->setTitleTab('usage'); - $this->setAutorefreshInterval(10); } /** From 8fe6a4b6868399c51716885ccb540a113014ca1a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 16 Jun 2026 11:13:35 +0200 Subject: [PATCH 012/221] CustomVariableForm: Use Callout to warn renaming of used custom variables --- application/forms/CustomVariableForm.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index a34c3a14a..af7018a37 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -8,11 +8,14 @@ use Icinga\Module\Director\Db; use Icinga\Module\Director\Web\Form\Element\IplBoolean; use Icinga\Web\Session; +use ipl\Html\Text; use ipl\I18n\Translation; +use ipl\Web\Common\CalloutType; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; use ipl\Web\Url; use ipl\Web\Widget\ButtonLink; +use ipl\Web\Widget\Callout; use PDO; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; @@ -286,11 +289,14 @@ protected function assemble(): void 'required' => true ])); - $this->getElement('confirm_rename_change')->addMessage( - '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->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', [ From 773de3e8e705afe8db3a090721c721f7a32cd637 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 16 Jun 2026 13:54:10 +0200 Subject: [PATCH 013/221] IcingaServiceForm: Support apply for rules for datafields of type array as well --- application/forms/IcingaServiceForm.php | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/application/forms/IcingaServiceForm.php b/application/forms/IcingaServiceForm.php index 8b4c0db82..6c2cdfe06 100644 --- a/application/forms/IcingaServiceForm.php +++ b/application/forms/IcingaServiceForm.php @@ -7,6 +7,7 @@ use Icinga\Exception\IcingaException; use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Auth\Permission; +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; @@ -702,12 +703,31 @@ protected function applyForVars(): array $properties = []; foreach ($vars as $var) { - $properties['host.vars.' . $var->key_name] = $var->label ?? $var->key_name . ' (' . $var->key_name . ')'; + $properties['host.vars.' . $var->key_name] = $var->label + ? $var->key_name . ' (' . $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]; } From a30caaabc7dd236bc88817b75c8b45d2901ebda2 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 16 Jun 2026 16:21:05 +0200 Subject: [PATCH 014/221] CustomVariablesForm: Unset empty dynamic dictionary from the object --- application/forms/CustomVariablesForm.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index 3b9678414..f78719549 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -318,7 +318,7 @@ protected function onSuccess(): void $value = $values[$key] ?? null; - if (is_array($value)) { + 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) { @@ -350,7 +350,7 @@ protected function onSuccess(): void ); } - if ($property['value_type'] !== 'dynamic-dictionary' && ! is_bool($value) && empty($value)) { + if (! is_bool($value) && empty($value)) { $vars->set($key, null); } else { $vars->set($key, $value); From 419ed6fea9b05a30438e3f23e88802e8bf59e775 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 17 Jun 2026 16:15:15 +0200 Subject: [PATCH 015/221] Use checkbox element for rename confirmation - The checkbox should not be set by default - Use the already obtained $usedCount to check if the custom variable is in use --- application/controllers/CustomvarController.php | 4 ++-- application/forms/CustomVariableForm.php | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/application/controllers/CustomvarController.php b/application/controllers/CustomvarController.php index ef94f0a81..f022e3da6 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -110,9 +110,9 @@ public function indexAction(): void ->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) { + ->on(CustomVariableForm::ON_SUBMIT, function (CustomVariableForm $form) use ($usedCount) { if ( - (int) $form->getValue('used') > 0 + $usedCount > 0 && $form->getPopulatedValue('confirm_rename_change') !== 'y' ) { $keyName = $form->getStoredKeyName(); diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index af7018a37..bc02b1567 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -6,7 +6,6 @@ use Icinga\Data\Filter\FilterException; use Icinga\Module\Director\Data\Db\DbConnection; use Icinga\Module\Director\Db; -use Icinga\Module\Director\Web\Form\Element\IplBoolean; use Icinga\Web\Session; use ipl\Html\Text; use ipl\I18n\Translation; @@ -284,10 +283,9 @@ protected function assemble(): void } if ($pendingRename) { - $this->addElement(new IplBoolean('confirm_rename_change', [ - 'label' => $this->translate('Confirm rename'), - 'required' => true - ])); + $this->addElement('checkbox', 'confirm_rename_change', [ + 'label' => $this->translate('Confirm rename') + ]); $this->addHtml(new Callout( CalloutType::Warning, From d62a332874985f6eca260110002ff673d4906f0f Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 24 Jun 2026 10:05:02 +0200 Subject: [PATCH 016/221] Change overridenVar to overriddenVar --- library/Director/IcingaConfig/IcingaConfig.php | 12 ++++++------ library/Director/Objects/IcingaService.php | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/library/Director/IcingaConfig/IcingaConfig.php b/library/Director/IcingaConfig/IcingaConfig.php index c1140a515..82b8adb54 100644 --- a/library/Director/IcingaConfig/IcingaConfig.php +++ b/library/Director/IcingaConfig/IcingaConfig.php @@ -566,16 +566,16 @@ protected function renderHostOverridableVars() globals.directorWarnOnceForServiceWithoutHost() } - var overridenVar = name - if (vars.overridenVar) { - overridenVar = vars.overridenVar - vars.remove("overridenVar") + var overriddenVar = name + if (vars.overriddenVar) { + overriddenVar = vars.overriddenVar + vars.remove("overriddenVar") } if (vars) { - vars += host.vars[DirectorOverrideVars][overridenVar] + vars += host.vars[DirectorOverrideVars][overriddenVar] } else { - vars = host.vars[DirectorOverrideVars][overridenVar] + vars = host.vars[DirectorOverrideVars][overriddenVar] } } } diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index e4bfe8808..fad0f4fd2 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -385,7 +385,7 @@ protected function renderObjectHeader() } $extraInfo = $propertyType !== null - ? sprintf("\n vars.overridenVar = %s\n", $varName) + ? sprintf("\n vars.overriddenVar = %s\n", $varName) : ''; return sprintf( From 3c2d3050c391fe1bc65d8c859a1386f101af27c3 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 24 Jun 2026 10:05:35 +0200 Subject: [PATCH 017/221] Change overridenVar to overriddenVar in tests --- .../Director/Objects/rendered/service_apply_for_array.out | 2 +- .../Director/Objects/rendered/service_apply_for_dict.out | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index f30059353..1d339aaf2 100644 --- a/test/php/library/Director/Objects/rendered/service_apply_for_array.out +++ b/test/php/library/Director/Objects/rendered/service_apply_for_array.out @@ -1,6 +1,6 @@ apply Service "___TEST___http-check" for (value in host.vars.___TEST___http_vhosts_fix) { - vars.overridenVar = "___TEST___http-check" + vars.overriddenVar = "___TEST___http-check" display_name = "HTTP + value" check_command = "http" assign where host.vars.___TEST___http_vhosts_fix == 1 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 index 7f1ff8cda..4c80982dd 100644 --- a/test/php/library/Director/Objects/rendered/service_apply_for_dict.out +++ b/test/php/library/Director/Objects/rendered/service_apply_for_dict.out @@ -1,6 +1,6 @@ apply Service "___TEST___disk-check" for (key => value in host.vars.___TEST___disk_checks_fix) { - vars.overridenVar = "___TEST___disk-check" + vars.overriddenVar = "___TEST___disk-check" display_name = "Disk + key" check_command = "disk" assign where host.vars.___TEST___disk_checks_fix == 1 From 4a835fa5f6968b4a304e5977340bffa20d843d94 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 25 Jun 2026 08:56:17 +0200 Subject: [PATCH 018/221] IcingaObjectFieldLoader: Restore getNameMap() method --- library/Director/Web/Form/IcingaObjectFieldLoader.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/library/Director/Web/Form/IcingaObjectFieldLoader.php b/library/Director/Web/Form/IcingaObjectFieldLoader.php index e0770f230..2e5a69a23 100644 --- a/library/Director/Web/Form/IcingaObjectFieldLoader.php +++ b/library/Director/Web/Form/IcingaObjectFieldLoader.php @@ -60,6 +60,16 @@ public function addFieldsToForm(DirectorObjectForm $form) return $this; } + /** + * Get element names to variable names map (Example: ['elName' => 'varName']) + * + * @return array + */ + public function getNameMap(): array + { + return $this->nameMap; + } + public function loadFieldsForMultipleObjects($objects) { $fields = array(); From 8dd3efe4ed45952503333c2032372a9df210a861 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 25 Jun 2026 08:57:13 +0200 Subject: [PATCH 019/221] IcingaMultiEditForm: Show deprecated custom properties on multi-select --- application/forms/IcingaMultiEditForm.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/application/forms/IcingaMultiEditForm.php b/application/forms/IcingaMultiEditForm.php index a2aa27180..7f0c0d11f 100644 --- a/application/forms/IcingaMultiEditForm.php +++ b/application/forms/IcingaMultiEditForm.php @@ -47,6 +47,12 @@ public function pickElementsFrom(QuickForm $form, $properties) public function setup() { $object = $this->object; + + $loader = new IcingaObjectFieldLoader($object); + $loader->prepareElements($this); + $loader->addFieldsToForm($this); + $this->varNameMap = $loader->getNameMap(); + if ($form = $this->relatedForm) { if ($form instanceof DirectorObjectForm) { $form->setDb($object->getConnection()) From 1a2484cfaf0869bfa686cd389843395cb7141692 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 25 Jun 2026 11:07:36 +0200 Subject: [PATCH 020/221] Fix value type conflict of linked data fields and custom variables with same name --- .../DictionaryElements/DictionaryItem.php | 12 ++++---- .../Web/Controller/ObjectController.php | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index a501864a4..94d6674e8 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -261,12 +261,14 @@ public function populate($values) && 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[$values['var']])) { - $values['var-search'] = $datalistEntries[$values['var']]; - $values['var-label'] = $values['var']; + if (isset($datalistEntries[$varValue])) { + $values['var-search'] = $datalistEntries[$varValue]; + $values['var-label'] = $varValue; } else { - $values['var-search'] = $values['var']; + $values['var-search'] = $varValue; } } @@ -341,7 +343,7 @@ public static function prepare(array $property): array && self::fetchItemType(Uuid::fromBytes($property['uuid'])) === 'string' ) { $dataListEntries = self::fetchDataListEntries(Uuid::fromBytes($property['uuid'])); - $value = $property['value'] ?? ''; + $value = is_string($property['value'] ?? null) ? $property['value'] : ''; if (isset($dataListEntries[$value])) { $values['var'] = $dataListEntries[$value]; $values['var-search'] = $value; diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index b736c7062..9cc660b8d 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -553,6 +553,36 @@ private function sendNewVarMultipartUpdate( $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; From 5e4d2503da90e79fe75655d4cae038bbd46cfb8a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 25 Jun 2026 11:47:34 +0200 Subject: [PATCH 021/221] IcingaServiceForm: Fix label (key) mapping for apply for custom vars --- application/forms/IcingaServiceForm.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/IcingaServiceForm.php b/application/forms/IcingaServiceForm.php index 6c2cdfe06..6f910a919 100644 --- a/application/forms/IcingaServiceForm.php +++ b/application/forms/IcingaServiceForm.php @@ -704,7 +704,7 @@ protected function applyForVars(): array $properties = []; foreach ($vars as $var) { $properties['host.vars.' . $var->key_name] = $var->label - ? $var->key_name . ' (' . $var->key_name . ')' + ? $var->label . ' (' . $var->key_name . ')' : $var->key_name; if ($var->value_type === 'dynamic-dictionary') { $this->dictionaryUuidMap['host.vars.' . $var->key_name] = $var->uuid; From 17902060fd15743885db31c507bcefa59b01cfab Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 25 Jun 2026 12:05:08 +0200 Subject: [PATCH 022/221] CustomVarRenderer: Fix order by for custom var types of new custom variables --- .../Icingadb/CustomVarRenderer.php | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index 75d969833..95bcbc912 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -333,6 +333,19 @@ public function prefetchForObject(Model $object): bool } } + 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. * @@ -375,10 +388,16 @@ protected function getObjectCustomProperties(IcingaObject $object, bool $isOverr ->joinLeft(['cpc' => 'director_datafield_category'], 'dp.category_id = cpc.id', []) ->where('iop.' . $type . '_uuid IN (?)', $uuids) ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) - ->order( - "FIELD(dp.value_type, 'string', 'number', 'bool', 'datalist-strict', 'datalist-non-strict'," - . " 'dynamic-array', 'fixed-dictionary', 'dynamic-dictionary')" - ) + ->order($this->valueTypeOrderExpr($db, [ + 'string', + 'number', + 'bool', + 'datalist-strict', + 'datalist-non-strict', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary' + ])) ->order('children') ->order('key_name'); From bedeae1fdf631c1eaa6d03ee62c17add77880f23 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 25 Jun 2026 12:06:23 +0200 Subject: [PATCH 023/221] CustomvarController: Prevent recreating of UuidInterface for parentuuid --- application/controllers/CustomvarController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/application/controllers/CustomvarController.php b/application/controllers/CustomvarController.php index f022e3da6..97677b51a 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -65,7 +65,6 @@ public function indexAction(): void } if ($parentUuid) { - $parentUuid = Uuid::fromString($parentUuid); $parent = $this->fetchProperty($parentUuid); if ($parent['parent_uuid'] !== null) { From 9a36e32a56de66b1354b1abe2fac93953723fe50 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 25 Jun 2026 12:15:17 +0200 Subject: [PATCH 024/221] CustomVariableForm: Allow value type change only when the custom var is not used --- application/forms/CustomVariableForm.php | 35 +++++++++++++----------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index bc02b1567..698fc52cd 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -553,27 +553,30 @@ private function updateExistingProperty( Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()) )) ); - } - if ($itemType && ($valueType === 'dynamic-array' || str_starts_with($valueType, 'datalist-'))) { - $this->db->insert('director_property', [ - 'uuid' => Db\DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $this->db->getDbAdapter()), - 'key_name' => '0', - 'value_type' => $itemType, - 'parent_uuid' => Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()), - ]); - - if (str_starts_with($valueType, 'datalist-')) { - $this->db->insert('director_property_datalist', [ - 'property_uuid' => Db\DbUtil::quoteBinaryCompat( + 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() ), - 'list_uuid' => Db\DbUtil::quoteBinaryCompat( - Db\DbUtil::binaryResult($datalist['uuid']), - $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 { From 8f42eb80f86929bfe0accad0442732034b8437d9 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 25 Jun 2026 12:15:56 +0200 Subject: [PATCH 025/221] CustomVariableForm: Fix incorrect use of Dbquery::where() --- application/forms/CustomVariableForm.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index 698fc52cd..2dd9360f5 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -584,7 +584,7 @@ private function updateExistingProperty( $this->db->select() ->from('director_property', ['key_name']) ->where( - 'uuid = ?', + 'uuid', Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()) ) ); From 900064a6153e7d2c1bc67846e136ecd467e63de9 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 25 Jun 2026 17:14:12 +0200 Subject: [PATCH 026/221] DeleteCustomVariableForm: Delete link to objects and then remove custom variable --- application/forms/DeleteCustomVariableForm.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index 56c379d1d..bc40e9344 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -244,14 +244,14 @@ protected function onSuccess(): void $this->removeObjectCustomVars($prop, $this->parent); $this->removeFromOverrideServiceVars($prop, $this->parent); - $db->delete('director_property', Filter::where('uuid', $quotedUuid)); - $db->delete('director_property', Filter::where('parent_uuid', $quotedUuid)); - - $objects = ['host', 'service', 'notification', 'command', 'user']; + $objects = ['host', 'service', 'notification', 'command', 'user', 'service_set']; foreach ($objects as $object) { $this->db->delete("icinga_{$object}_var", Filter::where('property_uuid', $quotedUuid)); } + $db->delete('director_property', Filter::where('uuid', $quotedUuid)); + $db->delete('director_property', Filter::where('parent_uuid', $quotedUuid)); + $db->getDbAdapter()->commit(); } From 3e13bf9435a1b121ace4fa6ea150327f66985dab Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 25 Jun 2026 17:14:58 +0200 Subject: [PATCH 027/221] Add foreign key constraints to icinga_*_var tables --- schema/mysql-migrations/upgrade_193.sql | 30 +++++++++++++++++++++++++ schema/mysql.sql | 25 ++++++++++++++++----- schema/pgsql-migrations/upgrade_193.sql | 30 +++++++++++++++++++++++++ schema/pgsql.sql | 30 ++++++++++++++++++++----- 4 files changed, 104 insertions(+), 11 deletions(-) diff --git a/schema/mysql-migrations/upgrade_193.sql b/schema/mysql-migrations/upgrade_193.sql index 0fcaf6a00..3c696bf09 100644 --- a/schema/mysql-migrations/upgrade_193.sql +++ b/schema/mysql-migrations/upgrade_193.sql @@ -149,21 +149,51 @@ CREATE TABLE director_property_datalist ( ALTER TABLE icinga_host_var ADD COLUMN property_uuid binary(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 binary(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 binary(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 binary(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 binary(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 binary(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 937eb3f85..2dc8bbb88 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -469,7 +469,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 ( @@ -868,7 +871,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 ( @@ -960,7 +966,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 ( @@ -1210,7 +1219,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 ( @@ -1362,7 +1374,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 ( diff --git a/schema/pgsql-migrations/upgrade_193.sql b/schema/pgsql-migrations/upgrade_193.sql index e0af90fcb..b5de67236 100644 --- a/schema/pgsql-migrations/upgrade_193.sql +++ b/schema/pgsql-migrations/upgrade_193.sql @@ -160,21 +160,51 @@ CREATE TABLE director_property_datalist ( 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 8d8eca65f..f52620d7f 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -653,7 +653,10 @@ CREATE TABLE icinga_command_var ( 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); @@ -900,7 +903,10 @@ CREATE TABLE icinga_host_var ( 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); @@ -1092,7 +1098,10 @@ CREATE TABLE icinga_service_var ( 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); @@ -1224,7 +1233,10 @@ CREATE TABLE icinga_service_set_var ( 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); @@ -1501,7 +1513,10 @@ CREATE TABLE icinga_user_var ( 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); @@ -1973,7 +1988,10 @@ CREATE TABLE icinga_notification_var ( 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); From a083015b1c5b5ed7330eabc17b15a0e980a0c31a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 30 Jun 2026 10:11:59 +0200 Subject: [PATCH 028/221] IcingaObjectHandler: Fix null dereference for the services when the allowOverride param is set --- library/Director/RestApi/IcingaObjectHandler.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index fd2910e53..e8020fde1 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -232,7 +232,10 @@ protected function handleApiRequest() throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); } + $data['vars'] = $overRiddenCustomVars; $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data); + + return; } else { $object = IcingaObject::createByType($type, $data, $db); $this->persistChanges($object); From b2374533c5d5cec24cf22a4684c780dad4cb31a1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 30 Jun 2026 10:15:41 +0200 Subject: [PATCH 029/221] HostController: Fix type mismatch of argument given to CustomVariablesForm::setInheritedServiceFrom() --- application/controllers/HostController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php index ac802a1ff..dc0d519fe 100644 --- a/application/controllers/HostController.php +++ b/application/controllers/HostController.php @@ -603,7 +603,7 @@ public function inheritedserviceAction() } else { $this->controls()->prepend($deactivateForm); $form = $this->prepareCustomPropertiesForm($parent, $host); - $form->setInheritedServiceFrom($from); + $form->setInheritedServiceFrom($from->getObjectName()); $form->setHostForService($host); $this->content()->add( From 0bf2dc8d6e9c1a80310719eedfe4867d23c027c5 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 30 Jun 2026 10:21:22 +0200 Subject: [PATCH 030/221] IcingaObjectHandler: Wrap PUT DELETE in a db transaction --- library/Director/RestApi/IcingaObjectHandler.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index e8020fde1..67c64ed12 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -251,23 +251,27 @@ protected function handleApiRequest() $objectVars = $object->vars(); if ($this->object->get('id') && $request->getMethod() === 'PUT') { $objectWhere = $db->getDbAdapter()->quoteInto("{$type}_id = ?", $this->object->get('id')); - $db->getDbAdapter()->delete( + $dbAdapter = $db->getDbAdapter(); + $dbAdapter->beginTransaction(); + $dbAdapter->delete( 'icinga_' . $type . '_var', $objectWhere ); $uuidExpr = DbUtil::quoteBinaryCompat( DbUtil::binaryResult($this->object->get('uuid')), - $db->getDbAdapter() + $dbAdapter ); - $db->getDbAdapter()->delete( + $dbAdapter->delete( 'icinga_' . $type . '_property', - $db->getDbAdapter()->quoteInto( + $dbAdapter->quoteInto( "{$type}_uuid = ?", $uuidExpr ) ); + $dbAdapter->commit(); + $objectVars = new CustomVariables(); } From b3ae826f5e67ef8b92318ef756fa7dcb23eae055 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 30 Jun 2026 10:22:54 +0200 Subject: [PATCH 031/221] CustomVariableForm: Fix incorrect early return while renaming of root custom variables --- application/forms/CustomVariableForm.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index 2dd9360f5..cfff2c256 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -652,7 +652,7 @@ private function updateUsedCustomVarNames(string $storedKeyName, mixed $keyName) ); } - return; + continue; } foreach ($objectCustomVars as $objectCustomVar) { From e8469aeb6c0b7e4b2b3ec4ee132c4e9b1f25ab2c Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 30 Jun 2026 10:55:03 +0200 Subject: [PATCH 032/221] DictionaryItem: Add match arms to handle incorrect value assignment of datalist types --- application/forms/DictionaryElements/DictionaryItem.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index 94d6674e8..b6af11671 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -469,11 +469,15 @@ public function getItem(): array // Use the default value for fixed-array items only if the fixed array does not have an inherited value if ($this->getElement('parent_type')->getValue() === 'fixed-array') { - match ($this->getElement('type')->getValue()) { + $type = $this->getElement('type')->getValue(); + $itemType = self::fetchItemType(Uuid::fromBytes($this->fields['uuid'])); + match ($type) { 'string' => $defaultValue = '', 'number' => $defaultValue = 0, 'bool' => $defaultValue = 'n', - 'fixed-array', 'dynamic-array' => $defaultValue = [] + 'fixed-array', 'dynamic-array' => $defaultValue = [], + 'datalist-strict', 'datalist-non-strict' => $defaultValue = $itemType === 'string' ? '' : [], + default => $defaultValue = null }; } From bb4a069e1a2bd1bec8c1bf44a3eb4ed4c88260d4 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 30 Jun 2026 11:00:33 +0200 Subject: [PATCH 033/221] CustomVariables: Use batch property lookup for custom variables This prevents database querying for each custom variable separately. --- .../CustomVariable/CustomVariables.php | 79 +++++++++++++------ 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php index ceab1d161..08e60ae8f 100644 --- a/library/Director/CustomVariable/CustomVariables.php +++ b/library/Director/CustomVariable/CustomVariables.php @@ -31,6 +31,9 @@ class CustomVariables implements Iterator, Countable, IcingaConfigRenderer /** @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', @@ -40,6 +43,46 @@ 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 = $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', []) + ->join(['iov' => 'icinga_' . $type . '_var'], 'iov.' . $type . '_id = io.id', []) + ->where('dp.key_name IN (?)', $keys) + ->where('io.id IN (?)', $ids); + + foreach ($object->getDb()->fetchAll($query) as $row) { + $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(); @@ -379,6 +422,10 @@ public function toConfigString($renderExpressions = false, ?IcingaObject $object { $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, $object); @@ -451,38 +498,24 @@ protected function renderSingleVar($key, $var, $renderExpressions = false, ?Icin ); } - $type = $object->getShortTableName(); $objectId = $object->get('id'); - $ids = $object->listAncestorIds(); - $ids[] = $objectId; - - $query = $object->getDb()->select()->from( - ['dp' => 'director_property'], - ['value_type'] - ) - ->join(['iop' => 'icinga_' . $type . '_property'], 'dp.uuid = iop.property_uuid', []) - ->join(['io' => 'icinga_' . $type], 'iop.' . $type . '_uuid = io.uuid', ['object_id' => 'io.id']) - ->join(['iov' => 'icinga_' . $type . '_var'], 'iov.' . $type . '_id = io.id', []) - ->where('dp.key_name = ?', $var->getKey()) - ->where('io.id IN (?)', $ids); - - $row = (array) $object->getDb()->fetchRow($query); + $cachedRow = $this->cachedCustomVariableTypes[$key] ?? null; if ( - isset($row['value_type']) - && $row['value_type'] === 'dynamic-dictionary' - && (int) $objectId !== (int) $row['object_id'] + $cachedRow !== null + && $cachedRow['value_type'] === 'dynamic-dictionary' + && (int) $objectId !== $cachedRow['object_id'] ) { return c::renderKeyOperatorValue( $this->renderKeyName($key), '+=', $var->toConfigStringPrefetchable($renderExpressions) ); - } else { - return c::renderKeyValue( - $this->renderKeyName($key), - $var->toConfigStringPrefetchable($renderExpressions) - ); } + + return c::renderKeyValue( + $this->renderKeyName($key), + $var->toConfigStringPrefetchable($renderExpressions) + ); } protected function renderKeyName($key) From 2064e81f7b96a5fe659ae53cf1d65facb8ecd54b Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 1 Jul 2026 12:46:05 +0200 Subject: [PATCH 034/221] DataController: Remove IcingaHostDictionaryMemberForm usage --- application/controllers/DataController.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/application/controllers/DataController.php b/application/controllers/DataController.php index 035027f51..7480db244 100644 --- a/application/controllers/DataController.php +++ b/application/controllers/DataController.php @@ -6,7 +6,6 @@ use Icinga\Exception\NotFoundError; use Icinga\Module\Director\Forms\DirectorDatalistEntryForm; use Icinga\Module\Director\Forms\DirectorDatalistForm; -use Icinga\Module\Director\Forms\IcingaHostDictionaryMemberForm; use Icinga\Module\Director\Forms\IcingaServiceDictionaryMemberForm; use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Objects\IcingaHost; @@ -194,12 +193,7 @@ public function dictionaryAction() 'object_name' => $field->getSetting('template_name') ], $connection); - if ($object instanceof IcingaHost) { - $form = new IcingaHostDictionaryMemberForm(); - } else { - $form = new IcingaServiceDictionaryMemberForm(); - } - + $form = new IcingaServiceDictionaryMemberForm(); $form->setDb($connection); if ($instance) { $instanceObject = $object::create([ From a8553885bf921c8f2a37c4c65f73897652196b5d Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 1 Jul 2026 14:25:59 +0200 Subject: [PATCH 035/221] IcingaServiceForm: Remove unused usages --- application/forms/IcingaServiceForm.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/application/forms/IcingaServiceForm.php b/application/forms/IcingaServiceForm.php index 6f910a919..a86731944 100644 --- a/application/forms/IcingaServiceForm.php +++ b/application/forms/IcingaServiceForm.php @@ -15,12 +15,8 @@ use Icinga\Module\Director\Objects\IcingaService; use Icinga\Module\Director\Objects\IcingaServiceSet; use Icinga\Module\Director\Web\Table\ObjectsTableHost; -use ipl\Html\Attributes; use ipl\Html\Html; use gipfl\IcingaWeb2\Link; -use ipl\Html\HtmlElement; -use ipl\Html\Text; -use PDO; use RuntimeException; class IcingaServiceForm extends DirectorObjectForm From 54c90f7fa6f768d85ee06079c2631fe8ab9d5e70 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 1 Jul 2026 17:27:32 +0200 Subject: [PATCH 036/221] Update relevant documentations --- doc/12-Handling-custom-variables.md | 324 +++++++++++++++++++++++++++- doc/15-Service-apply-for-example.md | 141 ++++++++++-- doc/30-Configuration-Baskets.md | 13 ++ doc/70-REST-API.md | 197 +++++++++++++++++ 4 files changed, 658 insertions(+), 17 deletions(-) diff --git a/doc/12-Handling-custom-variables.md b/doc/12-Handling-custom-variables.md index 3c9a9cf4e..16b7630f6 100644 --- a/doc/12-Handling-custom-variables.md +++ b/doc/12-Handling-custom-variables.md @@ -1,13 +1,329 @@ 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 | +| `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 item type of a `fixed-array`, +> `dynamic-array` or `fixed-dictionary`, and the fields inside a +> `dynamic-dictionary`'s sub-dictionary, may only be scalar (`string`, +> `number`, `bool`) or datalist (`datalist-strict`, `datalist-non-strict`) +> types. A nested field can itself be an array of datalist values, but it +> can never be a `fixed-array`, `fixed-dictionary` or +> `dynamic-array`/`dynamic-dictionary`. Also, `dynamic-dictionary` can +> only be defined as a top-level property; it cannot be nested inside +> another array or dictionary. +> +> 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 +``` + +##### `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" +} +``` + +##### `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, notification, and service sets) exposes a `Custom Variables` tab on +its object and template detail pages, next to the `Fields (Deprecated)` +tab. 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. + +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 +* the field is not marked as protected/hidden +* no custom variable with the same key already exists + +| Data field type | Custom variable type | +|------------------|-----------------------| +| `DataTypeString` | `string` | +| `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. + +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. + +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..96b1347f3 100644 --- a/doc/70-REST-API.md +++ b/doc/70-REST-API.md @@ -230,6 +230,203 @@ 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. Sending a variable that isn't yet configured under `Custom +Variables` at all always returns a `404 Not Found`, regardless of method +-- see [Error: 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**: every custom variable set directly on the object + is removed first, then the ones from the body are (re-)applied. Values + inherited from templates are not affected either way, since they + aren't stored on the object itself. + +#### 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 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" + } +} +``` + +#### Setting several variables at once + + POST director/host/variables?name=apitest + +```json +{ + "environment": "production", + "owner_teams": ["networking", "platform"] +} +``` + +##### Response + +``` +HTTP/1.1 200 OK +Content-Type: application/json +``` + +```json +{ + "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" +} +``` + +#### Error: variable not configured + +Trying to set a variable that hasn't been configured under `Custom +Variables` at all yet returns a `404`, for both `POST` and `PUT`: + +``` +HTTP/1.1 404 Not Found +Content-Type: application/json +``` + +```json +{ + "error": "No such custom variable: unknown_var" +} +``` + +#### 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 -------------- From 0249d9f7a0b41a6da8cf72271c309f636147c588 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 1 Jul 2026 17:47:41 +0200 Subject: [PATCH 037/221] Remove New-Custom-Property-Implementation.md --- doc/New-Custom-Property-Implementation.md | 763 ---------------------- 1 file changed, 763 deletions(-) delete mode 100644 doc/New-Custom-Property-Implementation.md diff --git a/doc/New-Custom-Property-Implementation.md b/doc/New-Custom-Property-Implementation.md deleted file mode 100644 index 2e8c4b000..000000000 --- a/doc/New-Custom-Property-Implementation.md +++ /dev/null @@ -1,763 +0,0 @@ -# New Custom Variable Support in Icinga Director - -## Overview - -A new **custom variable** support in Icinga Director has been implemented, with a focus on structured variable types (dictionaries and arrays). It extends the existing data-fields model with a new first-class `Property` concept that supports rich, nested types across all supported object types (host, service, command, user, notification), and brings them into configuration baskets, REST API, and apply-for rules - ---- - -## New Features - -### 1. Custom Variable Types - -A new `Custom Variables` section is available under the Icinga Director menu. Custom variables can be configured independently of data fields and support the following types: - -| Type | Description | -|-----------------------|-----------------------------------------------------------------------------------------------------------| -| `string` | Plain text value | -| `number` | Numeric value | -| `bool` | True/false value | -| `fixed-array` | Ordered list with a pre-defined structure; values assigned to preconfigured positions | -| `datalist-strict` | Only values from the chosen datalist can be assigned; can be stored as a single value or an array | -| `datalist-non-strict` | Values outside the chosen datalist are also accepted; can be stored as a single value or an array | -| `dynamic-array` | Uniform array where end-users can add values freely | -| `fixed-dictionary` | Key-value map with a fixed set of preconfigured keys | -| `dynamic-dictionary` | Key-value map where each key maps to a structured sub-dictionary; keys are added by end-users | - -> Only one level of nesting is allowed: an inner dictionary may contain non-dictionary values only. -> For `fixed-array`, all positions must be supplied on the object; none may be omitted. - -#### Type Examples (Infrastructure Monitoring Context) - -##### `string` -A plain text value. Useful for any single-value configuration parameter. - -``` -# On a host: the environment tag used to route alerts -vars.environment = "production" - -# On a service: the URL path to probe -vars.http_uri = "/api/health" - -# On a command: the path to the check plugin binary -vars.plugin_path = "/usr/lib/nagios/plugins/check_http" -``` - -##### `number` -A numeric value. Ideal for thresholds, timeouts, and retry counts. - -``` -# On a host: maximum check attempts before a hard state is raised -vars.max_check_attempts = 5 - -# On a service: SNMP polling interval in seconds -vars.snmp_timeout = 30 - -# On a notification: rate-limit delay in minutes between repeated alerts -vars.notification_interval = 60 -``` - -##### `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 -``` - -##### `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" -} -``` - -##### `dynamic-array` -A uniform array where end-users freely add values of the same type. Suitable for lists whose length varies per object. - -``` -# On a host: contact groups that should receive alerts for this host -vars.contact_groups = ["networking-ops", "on-call-primary", "noc"] - -# On a service: expected HTTP response strings (any of which satisfies the check) -vars.http_expect = ["HTTP/1.1 200", "HTTP/1.0 200"] - -# On a user: topics this user wants to receive notifications for -vars.notification_topics = ["disk", "cpu", "network"] -``` - -##### `dynamic-dictionary` -A dictionary where each top-level key is added freely by end-users, and the value for each key is a structured sub-dictionary with a preconfigured set of fields. Ideal for monitoring multiple similar resources on the same host (e.g. multiple disks, multiple virtual hosts). - -``` -# On a host: one entry per disk partition, each with threshold fields -vars.disk_checks += { - "/" = { - disk_partition = "/" - disk_wfree = "20%" - disk_cfree = "10%" - } - "/data" = { - disk_partition = "/data" - disk_wfree = "15%" - disk_cfree = "5%" - } -} - -# On a host: one entry per virtual host to probe via HTTP -vars.http_vhosts += { - "main-site" = { - http_address = "www.example.com" - http_uri = "/" - http_port = "443" - http_expect = ["HTTP/1.1 200"] - } - "api" = { - http_address = "api.example.com" - http_uri = "/health" - http_port = "443" - http_expect = ["HTTP/1.1 200", "HTTP/1.1 204"] - } -} -``` - ---- - -### 2. Enhanced Custom Variables UI - -- A dedicated **Custom Variables** tab is available on objects and templates for all supported object types (host, service, command, user, notification). -- Users can click **Add Custom Variable** to attach configured custom variables to a template. -- Inherited custom variables from parent templates are shown and editable on objects importing those templates. -- Dynamic dictionary items are **appended** (using `+=`) instead of overwritten, so values from multiple templates are merged on the final object. - ---- - -### 3. Apply-For Rule Support for Dictionaries and Arrays - -- Apply-for rules now support **dynamic arrays** and **dynamic dictionaries**, not just flat arrays. -- Service apply rules can reference dictionary items from the host via `$value.$` syntax. -- The `config` keyword in apply-for rules has been renamed to `value` for clarity. -- The IcingaService form shows nested dictionary key suggestions as a list for use in apply-for configuration. - -> Apply-for rules resolve dictionary keys from the **host's** custom variable definitions. The variable referenced in the apply-for field must be defined as a custom property on a host template. - -#### Example: Apply-For using a Dynamic Array (`http_vhosts_list`) - -**Scenario:** A host has a `dynamic-array` variable `http_vhosts_list` listing the virtual host addresses to probe. A service apply rule creates one HTTP check per entry. - -**Host variable (on `web-server-01`):** -``` -vars.http_vhosts_list = [ - "www.example.com", - "api.example.com", - "status.example.com" -] -``` - -**Service apply rule (configured in Icinga Director):** - -- **Apply for:** `http_vhosts_list` (the array variable on the host) -- **Service name pattern:** `http - $item$` -- **check_command:** `http` - -**Generated Icinga 2 config:** -``` -apply Service "http - " for (item in host.vars.http_vhosts_list) { - check_command = "http" - vars.http_address = item - vars.http_port = 443 - vars.http_uri = "/" - assign where host.vars.http_vhosts_list -} -``` - -**Result:** Three services are created on `web-server-01`: -- `http - www.example.com` → checks `www.example.com` -- `http - api.example.com` → checks `api.example.com` -- `http - status.example.com` → checks `status.example.com` - ---- - -#### Example: Apply-For using a Dynamic Dictionary (`disk_checks`) - -**Scenario:** A host has a `dynamic-dictionary` variable `disk_checks` where each key is a disk label and the value is a structured sub-dictionary with threshold fields. A service apply rule creates one disk check per entry. - -**Host variable (on `linux-server-01`, merged from templates):** -``` -vars.disk_checks += { - "root" = { - disk_partition = "/" - disk_wfree = "20%" - disk_cfree = "10%" - } - "data" = { - disk_partition = "/data" - disk_wfree = "15%" - disk_cfree = "5%" - } - "backup" = { - disk_partition = "/mnt/backup" - disk_wfree = "10%" - disk_cfree = "5%" - } -} -``` - -**Service apply rule (configured in Icinga Director):** - -- **Apply for:** `disk_checks` (the dictionary variable on the host) -- **Service name pattern:** `disk - $key$` -- **check_command:** `disk` -- **Custom variables** referencing the sub-dictionary fields via `$value.$`: - -| Variable | Value | -|----------|-------| -| `disk_partitions` | `$value.disk_partition$` | -| `disk_wfree` | `$value.disk_wfree$` | -| `disk_cfree` | `$value.disk_cfree$` | - -> The hint text below the Custom Variables section in the apply-rule form shows which `$value.*$` fields are available for the selected dictionary. - -**Generated Icinga 2 config:** -``` -apply Service "disk - " for (key => value in host.vars.disk_checks) { - check_command = "disk" - vars.disk_partitions = value.disk_partition - vars.disk_wfree = value.disk_wfree - vars.disk_cfree = value.disk_cfree - assign where host.vars.disk_checks -} -``` - -**Result:** Three services are created on `linux-server-01`: -- `disk - root` → checks `/` with warn=20%, crit=10% -- `disk - data` → checks `/data` with warn=15%, crit=5% -- `disk - backup` → checks `/mnt/backup` with warn=10%, crit=5% - -Because `disk_checks` uses `+=`, a child template or the host itself can add more partitions without overwriting entries from the parent template. All entries from every level of the template tree are merged into the final host config. - ---- - -### 4. REST API Endpoint for Updating Object Custom Variables - -A new REST API endpoint allows updating custom variables for an object directly: - -``` -PUT /director//variables? -``` - -| Object type | Query parameters | -|-------------|-----------------| -| Host | `name=` | -| Individual Service | `host=&name=` | -| Applied Service or Service Template | `name=` | -| User | `name=` | -| Notification | `name=` | -| Command | `name=` | - -The request body is a JSON object whose keys are variable names and values are the variable values. All standard types are accepted: strings, numbers, booleans, arrays, and nested dictionaries. - -> The custom variable must already be configured in the `director_property` table before it can be updated via the REST API. Attempting to update an unconfigured variable will return a `404 Not Found` error. - ---- - -#### Host — `linux-server-01` - -Updates disk check thresholds (dynamic-dictionary) and contact groups (dynamic-array): - -```bash -curl -k -u 'icingaadmin:icinga' \ - -H 'Accept: application/json' \ - -H 'Content-Type: application/json' \ - -X PUT 'http://localhost/icingaweb2/director/host/variables?name=linux-server-01' \ - -d '{ - "disk_checks": { - "root": { - "disk_partition": "/", - "disk_wfree": "20%", - "disk_cfree": "10%" - }, - "data": { - "disk_partition": "/data", - "disk_wfree": "15%", - "disk_cfree": "5%" - } - }, - "contact_groups": ["noc", "linux-ops"], - "environment": "production" - }' -``` - ---- - -#### Individual Service — `linux-server-01` / `http - www.example.com` - -Updates the HTTP check parameters (string, number, bool, dynamic-array): - -```bash -curl -k -u 'icingaadmin:icinga' \ - -H 'Accept: application/json' \ - -H 'Content-Type: application/json' \ - -X PUT 'http://localhost/icingaweb2/director/service/variables?host=linux-server-01&name=http%20-%20www.example.com' \ - -d '{ - "http_address": "www.example.com", - "http_port": 443, - "http_uri": "/health", - "ssl_verify": true, - "http_expect": ["HTTP/1.1 200", "HTTP/1.0 200"] - }' -``` - ---- - -#### Service Template — `generic-http-service` - -Updates default SNMP v3 credentials on a service template (fixed-dictionary): - -```bash -curl -k -u 'icingaadmin:icinga' \ - -H 'Accept: application/json' \ - -H 'Content-Type: application/json' \ - -X PUT 'http://localhost/icingaweb2/director/service/variables?name=generic-http-service' \ - -d '{ - "snmp_v3": { - "username": "monitoring", - "auth_protocol": "SHA", - "auth_password": "newAuthPass!", - "priv_protocol": "AES", - "priv_password": "newPrivPass!" - }, - "snmp_timeout": 30 - }' -``` - ---- - -#### User — `on-call-engineer` - -Updates notification preferences (string, bool, dynamic-array, fixed-dictionary): - -```bash -curl -k -u 'icingaadmin:icinga' \ - -H 'Accept: application/json' \ - -H 'Content-Type: application/json' \ - -X PUT 'http://localhost/icingaweb2/director/user/variables?name=on-call-engineer' \ - -d '{ - "pagerduty_key": "abc123def456", - "phone": "+1-555-0199", - "notify_on_recovery": true, - "notify_on_flapping": false, - "subscribed_services": ["disk", "http", "cpu", "memory"], - "working_hours": { - "timezone": "Europe/Berlin", - "start_time": "08:00", - "end_time": "18:00" - } - }' -``` - ---- - -#### Notification — `slack-host-notification` - -Updates Slack webhook details and escalation recipients (string, dynamic-array, fixed-dictionary): - -```bash -curl -k -u 'icingaadmin:icinga' \ - -H 'Accept: application/json' \ - -H 'Content-Type: application/json' \ - -X PUT 'http://localhost/icingaweb2/director/notification/variables?name=slack-host-notification' \ - -d '{ - "slack_webhook_url": "https://hooks.slack.com/services/T.../B.../newtoken", - "slack_channel": "#alerts-production", - "include_graphs": true, - "escalation_emails": ["noc@example.com", "oncall@example.com"], - "pagerduty": { - "integration_key": "xyz789", - "severity": "critical", - "component": "infrastructure", - "group": "platform" - } - }' -``` - ---- - -#### Command — `check_by_ssh` - -Updates default SSH connection parameters and plugin flags (string, number, bool, fixed-array): - -```bash -curl -k -u 'icingaadmin:icinga' \ - -H 'Accept: application/json' \ - -H 'Content-Type: application/json' \ - -X PUT 'http://localhost/icingaweb2/director/command/variables?name=check_by_ssh' \ - -d '{ - "by_ssh_logname": "monitoring", - "by_ssh_port": 22, - "by_ssh_quiet": false, - "by_ssh_arguments": ["-w", "20", "-c", "10"] - }' -``` - ---- - -### 5. Configuration Basket Support - -- Configuration baskets now include custom variables when snapshotting **templates**. -- The snapshot captures both the `Template` (with `properties` UUIDs) and a `CustomVariable` section containing the full property definitions and their nested items. -- Restoring a basket snapshot restores the associated custom variable schemas alongside the template. - ---- - -### 6. New `DirectorProperty` Object - -A new database-backed object `DirectorProperty` (`library/Director/Objects/DirectorProperty.php`) stores the custom variable schema: - -- UUID-based identity -- Hierarchical structure via `parent_uuid` (used for nested dictionary fields and array items) -- Supports all value types listed above -- Linked to templates via per-object-type join tables (`icinga__property`) - ---- - -## Database Changes - -A new migration (`schema/mysql-migrations/upgrade_193.sql`, `schema/pgsql-migrations/upgrade_193.sql`) and updated full schemas introduce: - -- `director_property` — stores custom variable definitions (`uuid`, `key_name`, `value_type`, `label`, `description`, `parent_uuid`) -- `director_property_datalist` — links datalist-typed properties to a Director datalist -- `icinga_host_property` — host-to-property join table -- `icinga_service_property` — service-to-property join table -- `icinga_command_property` — command-to-property join table -- `icinga_notification_property` — notification-to-property join table -- `icinga_user_property` — user-to-property join table -- `property_uuid` column added to all `icinga_*_var` tables to track which property a stored value belongs to - ---- - -### Datafield Migration - -Only fields that meet **all** of the following criteria are migrated: - -- Data type is one of: `String`, `Number`, `Boolean`, `Array`, `Datalist` -- The field has **no category** (`category_id IS NULL`) -- The field has **no duplicate variable name** (only one field per `varname`) -- The field is **not protected** (`visibility != 'hidden'`) -- No custom variable with the same `key_name` already exists - -Fields that do not meet these criteria are skipped and reported when `--verbose` is used. - -#### Data type mapping - -| Data field type | Custom variable `value_type` | -|-----------------|------------------------------| -| `DataTypeString` | `string` | -| `DataTypeNumber` | `number` | -| `DataTypeBoolean` | `bool` | -| `DataTypeArray` | `dynamic-array` (with a `string` child item) | -| `DataTypeDatalist` (strict / suggest\_strict) | `datalist-strict` | -| `DataTypeDatalist` (other) | `datalist-non-strict` | - -After creating the custom variable configurations, existing template bindings (host, service, notification, command, user) are carried over to the new `icinga__property` join tables. - -#### Usage - -```bash -# Preview what would be migrated — no DB changes are made -icingacli director migrate datafields --dry-run - -# Preview with per-field detail -icingacli director migrate datafields --dry-run --verbose - -# Run the migration -icingacli director migrate datafields - -# Run with per-field progress output -icingacli director migrate datafields --verbose - -# Remove migrated datafields after migration -icingacli director migrate datafields --delete -``` - -#### Example dry-run output - -Suppose the Director instance has eight data fields. Two are duplicates, one belongs to a category, one is a hidden/protected string, one has a type that cannot be mapped (`DataTypeSqlQuery`), and the remaining three are plain `String`, `Number`, and `Array` fields ready to migrate. - -``` -$ icingacli director migrate datafields --dry-run --verbose - -The following datafield types and the corresponding number of datafields can be migrated: -Data type: String | count: 1 -Data type: Number | count: 1 -Data type: Array | count: 1 -Total datafields that can be migrated: 3 - -The following datafields can not be migrated as there are duplicates: -Var name: environment | count: 2 -Total datafields that can not be migrated because of having duplicates: 2 - -The following number of datafields belong to a category and can not be migrated: 1 - -The following number of datafields are protected and can not be migrated: 1 - -The following datafield types and the corresponding number of datafields can not be migrated: -Data type: SqlQuery | count: 1 -Total datafields that can not be migrated because of incompatible datatypes with new custom property support: 1 - -Number of datafields that can not be migrated as the custom properties with the same name already exists: 0 -Migrating Data fields -Migration completed -Summary: -Total datafields migrated: 0 -Total datafields skipped: 5 -``` - -> `--dry-run` prints the summary but does **not** write anything to the database. - -#### Example live migration output - -Running without `--dry-run` performs the migration inside a single transaction: - -``` -$ icingacli director migrate datafields --verbose - -Migrating Data fields -[-] Skipping migrating datafield 'environment' as there are '2' datafields with same name -[-] Skipping migrating datafield 'category_field' as it belongs to a category -[-] Skipping migrating datafield 'secret_password' as it is protected -[-] Skipping migration of datafield 'custom_sql_query' as it has an unsupported datatype 'SqlQuery' -[+] Datafield 'max_check_attempts' successfully migrated -[+] Datafield 'agent_enabled' successfully migrated -[+] Datafield 'contact_groups' successfully migrated -Migration completed -Summary: -Total datafields migrated: 3 -Total datafields skipped: 5 -``` - -After a successful run, the three fields appear as custom variables in `director_property` and their template assignments are reflected in the `icinga__property` tables. - ---- - -## Custom Variables by Object Type - -The following examples show how the new custom variable types can be applied across each supported Icinga 2 object type. - -### Host - -Hosts are the primary target for the new system. All variable types are fully supported, and the dynamic-dictionary merge behaviour is most relevant here (values from multiple imported templates are combined). - -``` -# generic-linux-host template -vars.environment = "production" # string -vars.max_check_retries = 3 # number -vars.agent_enabled = true # bool -vars.ssh_args = ["monitoring", "22"] # fixed-array -vars.contact_groups = ["noc", "linux-ops"] # dynamic-array - -# Per-disk monitoring (dynamic-dictionary, merged across templates) -vars.disk_checks += { - "/" = { - disk_partition = "/" - disk_wfree = "20%" - disk_cfree = "10%" - } -} - -# MySQL credentials for the DB host template (fixed-dictionary) -vars.mysql_conn = { - host = "localhost" - port = "3306" - user = "icinga" - password = "secret" - database = "prod" -} -``` - ---- - -### Service - -Service custom variables drive check plugin arguments. Fixed types work well for connection parameters; dynamic arrays capture lists of expected strings; `string` and `number` types cover thresholds. - -``` -# generic-http-service template -vars.http_address = "www.example.com" # string -vars.http_port = 443 # number -vars.ssl_verify = true # bool -vars.http_expect = ["HTTP/1.1 200"] # dynamic-array - -# HTTP virtual-host probes assigned per service (dynamic-dictionary) -vars.http_vhosts += { - "main" = { - http_address = "www.example.com" - http_uri = "/" - http_port = "443" - } -} - -# SNMP v3 credentials for an SNMP service (fixed-dictionary) -vars.snmp_v3 = { - username = "monitoring" - auth_protocol = "SHA" - auth_password = "authpass" - priv_protocol = "AES" - priv_password = "privpass" -} - -# SSH-based check arguments (fixed-array: [user, port, identity-file]) -vars.ssh_args = ["monitoring", "22", "/etc/icinga2/ssh/id_rsa"] -``` - -**Apply-for rule:** A service apply rule iterates over `host.vars.disk_checks` and maps each disk entry to a service. Dictionary fields are referenced as `$value.disk_partition$`, `$value.disk_wfree$`, etc. - ---- - -### User - -User objects benefit from `string` (contact details), `bool` (opt-in flags), `dynamic-array` (subscribed topics), and `datalist-strict` (controlled notification preferences). - -``` -# on-call-engineer user -vars.pagerduty_key = "abc123def456" # string — PagerDuty integration key -vars.phone = "+1-555-0100" # string — SMS fallback number -vars.notify_on_recovery = true # bool — send recovery notifications -vars.notify_on_flapping = false # bool — suppress flapping alerts - -# Services this user wants to receive notifications for (dynamic-array) -vars.subscribed_services = ["disk", "http", "cpu", "memory"] - -# Preferred notification channels, from a "channels" datalist (datalist-strict) -vars.preferred_channel = "slack" - -# Working hours window (fixed-dictionary) -vars.working_hours = { - timezone = "Europe/Berlin" - start_time = "08:00" - end_time = "18:00" -} -``` - ---- - -### Command - -Command objects use custom variables to parameterise plugin invocations. `string` and `number` types set default argument values; `bool` toggles flags; `fixed-array` passes positional arguments; `fixed-dictionary` groups related plugin options. - -``` -# check_by_ssh command -vars.by_ssh_logname = "monitoring" # string — SSH user -vars.by_ssh_port = 22 # number — SSH port -vars.by_ssh_quiet = false # bool — suppress SSH banner - -# Positional arguments passed to the remote plugin (fixed-array) -vars.by_ssh_arguments = ["-w", "20", "-c", "10"] - -# SSL/TLS options for check_http (fixed-dictionary) -vars.http_ssl_opts = { - ssl_cert = "/etc/ssl/certs/ca-bundle.crt" - ssl_verify = "yes" - min_tls = "1.2" -} - -# Environments this command is valid in (datalist-strict, dynamic-array) -vars.valid_environments = ["production", "staging"] -``` - ---- - -### Notification - -Notification objects use custom variables to drive alert routing, templating, and channel selection. All scalar types apply; `fixed-dictionary` is useful for channel-specific config blocks; `dynamic-array` lists escalation recipients. - -``` -# slack-notification notification object -vars.slack_webhook_url = "https://hooks.slack.com/services/T.../B.../xxx" # string -vars.slack_channel = "#alerts-production" # string -vars.notification_icon = ":fire:" # string -vars.include_graphs = true # bool -vars.retry_count = 3 # number - -# Escalation recipients (dynamic-array) -vars.escalation_emails = ["noc@example.com", "oncall@example.com"] - -# PagerDuty routing block (fixed-dictionary) -vars.pagerduty = { - integration_key = "abc123" - severity = "critical" - component = "infrastructure" - group = "platform" -} - -# Escalation tier, from a "severity-levels" datalist (datalist-strict) -vars.escalation_tier = "P1" -``` - ---- - -## Known Limitations / Not Yet Implemented - -- **No visibility control** — custom variable values (e.g. passwords) are always visible; no masking support. -- **Apply-for rules** only work with `dynamic-array` and `dynamic-dictionary` types. From f4cdd3e1d3e259d4d508adea1ee6ea35a379cb64 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 2 Jul 2026 15:29:27 +0200 Subject: [PATCH 038/221] Mysql schema: Restructure and change uuids from binary to varbinary --- schema/mysql-migrations/upgrade_193.sql | 46 +++++----- schema/mysql.sql | 112 ++++++++++++------------ 2 files changed, 79 insertions(+), 79 deletions(-) diff --git a/schema/mysql-migrations/upgrade_193.sql b/schema/mysql-migrations/upgrade_193.sql index 3c696bf09..82e2fafdb 100644 --- a/schema/mysql-migrations/upgrade_193.sql +++ b/schema/mysql-migrations/upgrade_193.sql @@ -1,6 +1,6 @@ CREATE TABLE director_property ( - uuid binary(16) NOT NULL, - parent_uuid binary(16) DEFAULT NULL, + 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( @@ -16,7 +16,7 @@ CREATE TABLE director_property ( ) COLLATE utf8mb4_unicode_ci NOT NULL, category_id INT(10) UNSIGNED DEFAULT NULL, description text, - parent_uuid_v BINARY(16) AS (COALESCE(parent_uuid, 0x00000000000000000000000000000000)) STORED, + 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 @@ -27,8 +27,8 @@ CREATE TABLE director_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_host_property ( - host_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -44,8 +44,8 @@ CREATE TABLE icinga_host_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_service_property ( - service_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -61,8 +61,8 @@ CREATE TABLE icinga_service_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_command_property ( - command_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -78,8 +78,8 @@ CREATE TABLE icinga_command_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_notification_property ( - notification_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -95,8 +95,8 @@ CREATE TABLE icinga_notification_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_service_set_property ( - service_set_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -112,8 +112,8 @@ CREATE TABLE icinga_service_set_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_user_property ( - user_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -132,8 +132,8 @@ ALTER TABLE director_datalist ADD UNIQUE KEY (uuid); CREATE TABLE director_property_datalist ( - list_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + list_uuid varbinary(16) NOT NULL, + property_uuid varbinary(16) NOT NULL, PRIMARY KEY (list_uuid, property_uuid), CONSTRAINT director_list_property_list FOREIGN KEY list (list_uuid) @@ -147,7 +147,7 @@ CREATE TABLE director_property_datalist ( ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin; ALTER TABLE icinga_host_var - ADD COLUMN property_uuid binary(16) DEFAULT NULL; + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; ALTER TABLE icinga_host_var ADD CONSTRAINT icinga_host_var_property_uuid @@ -155,7 +155,7 @@ ALTER TABLE icinga_host_var REFERENCES director_property (uuid); ALTER TABLE icinga_service_var - ADD COLUMN property_uuid binary(16) DEFAULT NULL; + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; ALTER TABLE icinga_service_var ADD CONSTRAINT icinga_service_var_property_uuid @@ -163,7 +163,7 @@ ALTER TABLE icinga_service_var REFERENCES director_property (uuid); ALTER TABLE icinga_command_var - ADD COLUMN property_uuid binary(16) DEFAULT NULL; + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; ALTER TABLE icinga_command_var ADD CONSTRAINT icinga_command_var_property_uuid @@ -171,7 +171,7 @@ ALTER TABLE icinga_command_var REFERENCES director_property (uuid); ALTER TABLE icinga_notification_var - ADD COLUMN property_uuid binary(16) DEFAULT NULL; + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; ALTER TABLE icinga_notification_var ADD CONSTRAINT icinga_notification_var_property_uuid @@ -179,7 +179,7 @@ ALTER TABLE icinga_notification_var REFERENCES director_property (uuid); ALTER TABLE icinga_service_set_var - ADD COLUMN property_uuid binary(16) DEFAULT NULL; + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; ALTER TABLE icinga_service_set_var ADD CONSTRAINT icinga_service_set_var_property_uuid @@ -187,7 +187,7 @@ ALTER TABLE icinga_service_set_var REFERENCES director_property (uuid); ALTER TABLE icinga_user_var - ADD COLUMN property_uuid binary(16) DEFAULT NULL; + ADD COLUMN property_uuid varbinary(16) DEFAULT NULL; ALTER TABLE icinga_user_var ADD CONSTRAINT icinga_user_var_property_uuid diff --git a/schema/mysql.sql b/schema/mysql.sql index 2dc8bbb88..f862a25b6 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -249,6 +249,52 @@ 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, + description text DEFAULT NULL, + value_type enum( + 'string', + 'number', + 'bool', + 'fixed-array', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary', + 'datalist-strict', + 'datalist-non-strict' + ) COLLATE utf8mb4_unicode_ci NOT NULL, + category_id INT(10) UNSIGNED DEFAULT NULL, + parent_uuid_v 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 +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +ALTER TABLE director_datalist + ADD UNIQUE KEY (uuid); + +CREATE TABLE director_property_datalist ( + list_uuid varbinary(16) NOT NULL, + property_uuid varbinary(16) NOT NULL, + PRIMARY KEY (list_uuid, property_uuid), + CONSTRAINT director_list_property_list + FOREIGN KEY list (list_uuid) + REFERENCES director_datalist (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT director_property_list_property + FOREIGN KEY property (property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin; + CREATE TABLE icinga_zone ( id INT(10) UNSIGNED AUTO_INCREMENT NOT NULL, uuid VARBINARY(16) NOT NULL, @@ -655,34 +701,6 @@ CREATE TABLE icinga_host_field ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -CREATE TABLE director_property ( - uuid varbinary(16) NOT NULL, - parent_uuid varbinary(16) NULL DEFAULT NULL, - key_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, - label varchar(255) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, - description text DEFAULT NULL, - value_type enum( - 'string', - 'number', - 'bool', - 'fixed-array', - 'dynamic-array', - 'fixed-dictionary', - 'dynamic-dictionary', - 'datalist-strict', - 'datalist-non-strict' - ) COLLATE utf8mb4_unicode_ci NOT NULL, - category_id INT(10) UNSIGNED DEFAULT NULL, - parent_uuid_v BINARY(16) AS (COALESCE(parent_uuid, 0x00000000000000000000000000000000)) STORED, - PRIMARY KEY (uuid), - UNIQUE INDEX unique_name_parent_uuid (key_name, parent_uuid_v), - CONSTRAINT director_property_category - FOREIGN KEY category (category_id) - REFERENCES director_datafield_category (id) - ON DELETE RESTRICT - ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - CREATE TABLE icinga_host_property ( host_uuid varbinary(16) NOT NULL, property_uuid varbinary(16) NOT NULL, @@ -2534,8 +2552,8 @@ CREATE TABLE icinga_usergroup_user_resolved ) ENGINE = InnoDB DEFAULT CHARSET = utf8; CREATE TABLE icinga_service_property ( - service_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -2551,8 +2569,8 @@ CREATE TABLE icinga_service_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_command_property ( - command_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -2568,8 +2586,8 @@ CREATE TABLE icinga_command_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_notification_property ( - notification_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -2585,8 +2603,8 @@ CREATE TABLE icinga_notification_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_service_set_property ( - service_set_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -2602,8 +2620,8 @@ CREATE TABLE icinga_service_set_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_user_property ( - user_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, + 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 @@ -2618,24 +2636,6 @@ CREATE TABLE icinga_user_property ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -ALTER TABLE director_datalist - ADD UNIQUE KEY (uuid); - -CREATE TABLE director_property_datalist ( - list_uuid binary(16) NOT NULL, - property_uuid binary(16) NOT NULL, - PRIMARY KEY (list_uuid, property_uuid), - CONSTRAINT director_list_property_list - FOREIGN KEY list (list_uuid) - REFERENCES director_datalist (uuid) - ON DELETE CASCADE - ON UPDATE CASCADE, - CONSTRAINT director_property_list_property - FOREIGN KEY property (property_uuid) - REFERENCES director_property (uuid) - ON DELETE CASCADE -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin; - INSERT INTO director_schema_migration (schema_version, migration_time) VALUES (193, NOW()); From 7a3bc20195903683233f9ac3cfb8cedd5061c909 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 2 Jul 2026 15:53:08 +0200 Subject: [PATCH 039/221] mysql.sql: Change charset to utf8mb4 for director_property and icinga_host_property --- schema/mysql.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schema/mysql.sql b/schema/mysql.sql index f862a25b6..dea37979b 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -275,7 +275,7 @@ CREATE TABLE director_property ( REFERENCES director_datafield_category (id) ON DELETE RESTRICT ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; ALTER TABLE director_datalist ADD UNIQUE KEY (uuid); @@ -716,7 +716,7 @@ CREATE TABLE icinga_host_property ( REFERENCES director_property (uuid) ON DELETE CASCADE ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_host_var ( host_id INT(10) UNSIGNED NOT NULL, From 6f745e0b04b7320676a9dee6364fd5f2e2700f1e Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 2 Jul 2026 17:09:59 +0200 Subject: [PATCH 040/221] DirectorProperty: Safely unset parent_uuid_v for mysql database --- library/Director/Objects/DirectorProperty.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index 110490b83..0fc05c946 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -47,14 +47,24 @@ class DirectorProperty extends DbObject protected function setDbProperties($properties) { - unset($properties->parent_uuid_v); // hack to ignore virtual column, need a better solution + $connection = $this->getConnection(); + if (! is_array($properties)) { + $properties = (array) $properties; + } + + if ($connection && $connection->isMysql() && isset($properties['parent_uuid_v'])) { + unset($properties['parent_uuid_v']); // hack to ignore virtual column, need a better solution + } return parent::setDbProperties($properties); } public function setProperties($props) { - unset($props['parent_uuid_v']); + $connection = $this->getConnection(); + if ($connection && $connection->isMysql() && isset($props['parent_uuid_v'])) { + unset($props['parent_uuid_v']); + } return parent::setProperties($props); } From 8bfff3f4c124722ee23a60de4643ca55e2b3ac88 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 2 Jul 2026 17:17:17 +0200 Subject: [PATCH 041/221] DbObject: Check if the property $autoincKeyName before accessing it --- library/Director/Data/Db/DbObject.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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; } From d81b0ef611c1e8fb34b1f3f6fd4c78ce6a61b5ba Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 3 Jul 2026 13:00:49 +0200 Subject: [PATCH 042/221] DeleteCustomVariableForm: Delete datalist links and properties for all descendants --- .../forms/DeleteCustomVariableForm.php | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index bc40e9344..6c6a84a62 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -237,9 +237,14 @@ protected function onSuccess(): void $db->getDbAdapter()->beginTransaction(); $prop = $this->property; - if (str_starts_with($prop['value_type'], 'datalist-')) { - $db->delete('director_property_datalist', Filter::where('property_uuid', $quotedUuid)); - } + // 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->delete('director_property_datalist', Filter::where('property_uuid', $quotedAllUuids)); $this->removeObjectCustomVars($prop, $this->parent); $this->removeFromOverrideServiceVars($prop, $this->parent); @@ -249,12 +254,40 @@ protected function onSuccess(): void $this->db->delete("icinga_{$object}_var", Filter::where('property_uuid', $quotedUuid)); } - $db->delete('director_property', Filter::where('uuid', $quotedUuid)); - $db->delete('director_property', Filter::where('parent_uuid', $quotedUuid)); + $db->delete('director_property', Filter::where('uuid', $quotedAllUuids)); $db->getDbAdapter()->commit(); } + /** + * 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 = array_merge($descendants, $children); + $parents = $children; + } + + return $descendants; + } + /** * Remove the deleted property's key from all hosts' _override_servicevars custom variable * From 41952a8fe95dda10259864734ad56194335ebd1e Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 3 Jul 2026 13:03:15 +0200 Subject: [PATCH 043/221] CustomVariableFormi: Delete datalist links and properties for all descendantes --- application/forms/CustomVariableForm.php | 57 ++++++++++++++++++++---- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index cfff2c256..f3de90a60 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -538,19 +538,31 @@ private function updateExistingProperty( if (! $used) { $dbProperty = $this->fetchProperty($this->uuid); if ($dbProperty['value_type'] !== $valueType) { - $this->db->delete( - 'director_property', - Filter::matchAll(Filter::where( - 'parent_uuid', - Db\DbUtil::quoteBinaryCompat($this->uuid->getBytes(), $this->db->getDbAdapter()) - )) - ); + $db = $this->db->getDbAdapter(); + // 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($this->uuid->getBytes(), $this->db->getDbAdapter()) + Db\DbUtil::quoteBinaryCompat( + array_merge([$this->uuid->getBytes()], $descendantUuids), + $db + ) )) ); @@ -601,6 +613,35 @@ private function updateExistingProperty( ); } + /** + * 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 * From 44f709a9a340ce944c0adb2a58f3ab4ab4cbae3c Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 3 Jul 2026 13:12:28 +0200 Subject: [PATCH 044/221] director_property_datalist table must allow only one link per director property --- schema/mysql-migrations/upgrade_193.sql | 1 + schema/mysql.sql | 1 + schema/pgsql-migrations/upgrade_193.sql | 1 + schema/pgsql.sql | 1 + 4 files changed, 4 insertions(+) diff --git a/schema/mysql-migrations/upgrade_193.sql b/schema/mysql-migrations/upgrade_193.sql index 82e2fafdb..28ce31f1f 100644 --- a/schema/mysql-migrations/upgrade_193.sql +++ b/schema/mysql-migrations/upgrade_193.sql @@ -135,6 +135,7 @@ 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) diff --git a/schema/mysql.sql b/schema/mysql.sql index dea37979b..f796fc770 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -284,6 +284,7 @@ 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) diff --git a/schema/pgsql-migrations/upgrade_193.sql b/schema/pgsql-migrations/upgrade_193.sql index b5de67236..d225d325b 100644 --- a/schema/pgsql-migrations/upgrade_193.sql +++ b/schema/pgsql-migrations/upgrade_193.sql @@ -145,6 +145,7 @@ 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) diff --git a/schema/pgsql.sql b/schema/pgsql.sql index f52620d7f..206710031 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -358,6 +358,7 @@ 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) From c7dca535734d2ea2caa77732d79bd37de5f9db36 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Sun, 5 Jul 2026 23:48:28 +0200 Subject: [PATCH 045/221] Add admin permission for editing the custom variables --- application/controllers/CustomvarController.php | 2 ++ application/controllers/VariablesController.php | 7 +++++++ configuration.php | 1 + 3 files changed, 10 insertions(+) diff --git a/application/controllers/CustomvarController.php b/application/controllers/CustomvarController.php index 97677b51a..40671d22b 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -38,6 +38,8 @@ public function init(): void { parent::init(); + $this->assertPermission('director/admin'); + $uuid = $this->params->shift('uuid'); if ($uuid !== null) { $this->uuid = Uuid::fromString($uuid); diff --git a/application/controllers/VariablesController.php b/application/controllers/VariablesController.php index 208e356e6..1f814ed54 100644 --- a/application/controllers/VariablesController.php +++ b/application/controllers/VariablesController.php @@ -15,6 +15,13 @@ class VariablesController extends CompatController { + protected function prepareInit() + { + parent::prepareInit(); + + $this->assertPermission('director/admin'); + } + public function indexAction(): void { $this->addTitleTab($this->translate('Custom Variables')); diff --git a/configuration.php b/configuration.php index b560cb4af..65ff6fc1b 100644 --- a/configuration.php +++ b/configuration.php @@ -177,6 +177,7 @@ ->setPermission(Permission::DEPLOYMENTS); $section->add(N_('Custom Variables')) ->setUrl('director/variables') + ->setPermission(Permission::ADMIN) ->setPriority(903); $cssDirectory = $this->getCssDir(); From f5c28118da0e46b33ff500b5c523bd6c9df59c2a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 6 Jul 2026 12:59:26 +0200 Subject: [PATCH 046/221] IcingaObjectHandler: Fix referencing null `$object` property --- library/Director/RestApi/IcingaObjectHandler.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index 67c64ed12..1e9cf3151 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -249,8 +249,8 @@ protected function handleApiRequest() } $objectVars = $object->vars(); - if ($this->object->get('id') && $request->getMethod() === 'PUT') { - $objectWhere = $db->getDbAdapter()->quoteInto("{$type}_id = ?", $this->object->get('id')); + if ($object->get('id') && $request->getMethod() === 'PUT') { + $objectWhere = $db->getDbAdapter()->quoteInto("{$type}_id = ?", $object->get('id')); $dbAdapter = $db->getDbAdapter(); $dbAdapter->beginTransaction(); $dbAdapter->delete( @@ -259,7 +259,7 @@ protected function handleApiRequest() ); $uuidExpr = DbUtil::quoteBinaryCompat( - DbUtil::binaryResult($this->object->get('uuid')), + DbUtil::binaryResult($object->get('uuid')), $dbAdapter ); $dbAdapter->delete( From 868cc79cfe3708fc5b340785d32846303db8582c Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 6 Jul 2026 13:14:27 +0200 Subject: [PATCH 047/221] doc/70-REST-API.md: Fix the documentation --- doc/70-REST-API.md | 68 +++++++++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/doc/70-REST-API.md b/doc/70-REST-API.md index 96b1347f3..dc0c4e3a2 100644 --- a/doc/70-REST-API.md +++ b/doc/70-REST-API.md @@ -231,11 +231,11 @@ 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: +endpoint. POST director//variables? PUT director//variables? @@ -246,14 +246,14 @@ 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. Sending a variable that isn't yet configured under `Custom -Variables` at all always returns a `404 Not Found`, regardless of method --- see [Error: variable not configured](#Custom-Variables-not-configured) -below. +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: +body. * `POST` **merges**: only the keys you send are touched, all other existing variables on the object are left untouched. @@ -323,6 +323,11 @@ body: ##### 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 @@ -330,11 +335,16 @@ Content-Type: application/json ```json { - "environment": "production", - "owner_teams": [ - "networking", - "platform" - ] + "object_name": "apitest", + "object_type": "object", + "address": "127.0.0.1", + "vars": { + "environment": "production", + "owner_teams": [ + "networking", + "platform" + ] + } } ``` @@ -351,14 +361,15 @@ before: { "environment": "production" } ``` -#### Attaching a variable to a template for the first time +#### 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: +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 @@ -368,7 +379,7 @@ creates the attachment and then stores the value in the same call: Sending the very same body with `POST` instead fails, because `POST` is not allowed to change which custom variables are attached to a -template: +template. ``` HTTP/1.1 404 Not Found @@ -382,9 +393,9 @@ Content-Type: application/json ``` The same restriction applies to non-template objects (hosts, services, -...) regardless of method: a variable must first be attached to one of +...) 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: +itself. ``` HTTP/1.1 404 Not Found @@ -397,10 +408,17 @@ Content-Type: application/json } ``` -#### Error: variable not configured +#### Variable not configured + +The message depends on how the variable is unknown. -Trying to set a variable that hasn't been configured under `Custom -Variables` at all yet returns a `404`, for both `POST` and `PUT`: +* 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 @@ -409,21 +427,21 @@ Content-Type: application/json ```json { - "error": "No such custom variable: unknown_var" + "error": "'unknown_var' is not configured in Icinga Director as a custom variable" } ``` #### GET -`GET` is also accepted on this endpoint: +`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. +its custom variables; there's no dedicated "variables only" response. Use the `properties` parameter if you only want the `vars` property -back: +back. GET director/host/variables?name=apitest&properties=vars From 1e32e18e4f15838b8afb03ad12112da0ab73e712 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 6 Jul 2026 13:24:54 +0200 Subject: [PATCH 048/221] Apply-for configuration: Provide backward compatibility for renaming 'config' -> 'value' --- .../IcingaConfig/IcingaConfigHelper.php | 7 +++-- library/Director/Objects/IcingaService.php | 10 +++++-- .../Director/Objects/IcingaServiceTest.php | 26 +++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/library/Director/IcingaConfig/IcingaConfigHelper.php b/library/Director/IcingaConfig/IcingaConfigHelper.php index d82debc54..03d4d4632 100644 --- a/library/Director/IcingaConfig/IcingaConfigHelper.php +++ b/library/Director/IcingaConfig/IcingaConfigHelper.php @@ -396,7 +396,9 @@ public static function isValidMacroName(string $name, ?array $whiteList = null): return $hasMacroPattern; } - if (in_array($name, $whiteList, true)) { + // 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; } @@ -441,7 +443,8 @@ public static function renderStringWithVariables($string, ?array $whiteList = nu ); } - $parts[] = $macroName; + $alias = $whiteList[$macroName] ?? null; + $parts[] = is_string($alias) ? $alias : $macroName; $offset = $i + 1; } diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index fad0f4fd2..38293260c 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -694,10 +694,16 @@ public function vars() $result = $this->db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); $propertyType = $this->fetchApplyForPropertyType($applyFor); - $isApplyFor = $propertyType === 'dynamic-dictionary'; + $isApplyForDictionary = $propertyType === 'dynamic-dictionary'; $whiteList = ['value', 'host.*', 'value[*]', 'value[*].*']; - if ($isApplyFor) { + if ($isApplyForDictionary) { $whiteList[] = 'key'; + } 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'; } foreach ($result as $row) { diff --git a/test/php/library/Director/Objects/IcingaServiceTest.php b/test/php/library/Director/Objects/IcingaServiceTest.php index 9234d0b4e..ac11224cb 100644 --- a/test/php/library/Director/Objects/IcingaServiceTest.php +++ b/test/php/library/Director/Objects/IcingaServiceTest.php @@ -234,6 +234,32 @@ 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.test1'; + $service->assign_filter = 'host.vars.env="test"'; + $service->{'vars.legacy_macro'} = '/dev/$config$'; + + $this->assertStringContainsString( + 'vars.legacy_macro = "/dev/" + value', + (string) $service + ); + } + protected function host() { return IcingaHost::create(array( From d02bae9a727f3e233b2081f7f37d8f853aae8a17 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 6 Jul 2026 14:55:39 +0200 Subject: [PATCH 049/221] IcingaService: Fix fetchItemsForDictionary() for postgres --- library/Director/Objects/IcingaService.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index 38293260c..f26e0d067 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -746,7 +746,10 @@ protected function fetchItemsForDictionary(string $uuid): array ] ) ->join(['parent_dp' => 'director_property'], 'dp.parent_uuid = parent_dp.uuid', []) - ->where("dp.parent_uuid = ?", $uuid); + ->where( + 'dp.parent_uuid = ?', + Db\DbUtil::quoteBinaryCompat(Db\DbUtil::binaryResult($uuid), $this->db) + ); return $this->db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); } From 3e877aa6d4fddd50f6a6215b11893ca729c24971 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 6 Jul 2026 15:13:31 +0200 Subject: [PATCH 050/221] CustomVariableForm: Fix static analysis errors --- application/forms/CustomVariableForm.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index f3de90a60..303307902 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -270,15 +270,13 @@ protected function assemble(): void ); if ($used) { - $this->getElement('item_type') - ->setAttribute( - 'title', - $this->translate( - 'This property is used in one or more templates' - . ' and hence the item type cannot be changed.' - ) - ) - ->setAttribute('disabled', true); + $this->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, + ]); } } From aeda63fc0de4d118775530f531fe47f4a0d85bee Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 6 Jul 2026 16:14:31 +0200 Subject: [PATCH 051/221] DirectorProperty: Resolve datalist during property import --- library/Director/Objects/DirectorProperty.php | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index 0fc05c946..499accabb 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -2,6 +2,7 @@ namespace Icinga\Module\Director\Objects; +use Icinga\Authentication\Auth; use Icinga\Exception\NotFoundError; use Icinga\Module\Director\Data\Db\DbObject; use Icinga\Module\Director\Db; @@ -256,6 +257,40 @@ public static function fromDbRow($row, Db $connection) } + /** + * 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 */ @@ -272,18 +307,22 @@ public static function import(stdClass $plain, Db $db): static if ($uuid) { $uuid = Uuid::fromString($uuid); if (isset($plain->datalist)) { - $datalist = DirectorDatalist::loadOptional($plain->datalist, $db); - if (! $datalist && is_string($plain->datalist)) { - $datalist = DirectorDatalist::create(['list_name' => $plain->datalist], $db); - } - + $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; @@ -414,11 +453,7 @@ private function importItems(array $items, Db $db): array $datalist = null; if (isset($value->datalist)) { - $datalist = DirectorDatalist::loadOptional($value->datalist, $db); - if (! $datalist && is_string($value->datalist)) { - $datalist = DirectorDatalist::create(['list_name' => $value->datalist], $db); - } - + $datalist = static::resolveImportedDatalist($value->datalist, $db); unset($value->datalist); } From 6d8d8adb4b9abd6610290c311bb7de645c8290e1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 6 Jul 2026 16:49:52 +0200 Subject: [PATCH 052/221] IcingaService: escape embedded quotes using IcingaConfigHelper::renderString() --- library/Director/Objects/IcingaService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index f26e0d067..5ca98f4fa 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -369,7 +369,7 @@ protected function renderObjectHeader() $propertyType = $this->fetchApplyForPropertyType($applyForVar); $isApplyFor = $propertyType === 'dynamic-dictionary'; - $varName = '"' . $name . '"'; + $varName = c::renderString($name); if (c::stringHasMacro($name)) { $extraName = c::renderKeyValue('name', c::renderStringWithVariables($name)); From 550937520ed4b90ce262ead60eedd11d47c04c2a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 6 Jul 2026 16:51:49 +0200 Subject: [PATCH 053/221] schema Mysql: Add ON UPDATE CASCADE to director_property_list_property for consistency across databases --- schema/mysql-migrations/upgrade_193.sql | 1 + schema/mysql.sql | 1 + 2 files changed, 2 insertions(+) diff --git a/schema/mysql-migrations/upgrade_193.sql b/schema/mysql-migrations/upgrade_193.sql index 28ce31f1f..47550a78b 100644 --- a/schema/mysql-migrations/upgrade_193.sql +++ b/schema/mysql-migrations/upgrade_193.sql @@ -145,6 +145,7 @@ CREATE TABLE director_property_datalist ( 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 diff --git a/schema/mysql.sql b/schema/mysql.sql index f796fc770..7a9d0ff57 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -294,6 +294,7 @@ CREATE TABLE director_property_datalist ( 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 ( From b310356ff8f892f1c4ca843ac14a13330bdf765e Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 6 Jul 2026 17:14:02 +0200 Subject: [PATCH 054/221] DirectorPropertyTest: Add regression tests for dynamic-dictionary datalist child/grandchild restore --- .../Director/Objects/DirectorPropertyTest.php | 178 +++++++++++++++++- 1 file changed, 175 insertions(+), 3 deletions(-) diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 7ae27e922..94031feda 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -221,6 +221,125 @@ public function testDatalistImportRestoresDatalistLink(): void $this->assertEquals($listName, $restored->getDatalist()->get('list_name')); } + /** + * Regression test: 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' => [ + 'choice' => $this->datalistItemPlain('choice', $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'); + } + + /** + * Same regression as above, one level deeper: the not-yet-existing datalist is referenced + * by a GRANDCHILD (dynamic-dictionary -> fixed-dictionary -> datalist-strict). + */ + public function testDatalistGrandchildOfDynamicDictionaryIsPersistedWhenListDoesNotExistYet(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $parentKeyName = self::PREFIX . 'dict_with_new_list_grandchild'; + $listName = self::PREFIX . 'never_seen_list_grandchild'; + $this->createdKeyNames[] = $parentKeyName; + $this->createdListNames[] = $listName; + + $this->assertFalse(DirectorDatalist::exists($listName, $db), 'Precondition: datalist must not exist yet'); + + $parentUuid = Uuid::uuid4()->toString(); + $groupUuid = Uuid::uuid4()->toString(); + $plain = (object) [ + 'uuid' => $parentUuid, + 'key_name' => $parentKeyName, + 'value_type' => 'dynamic-dictionary', + 'label' => 'Dict With New List Grandchild', + 'parent_uuid' => null, + 'category' => null, + 'description' => null, + 'items' => [ + 'group' => (object) [ + 'uuid' => $groupUuid, + 'key_name' => 'group', + 'value_type' => 'fixed-dictionary', + 'label' => null, + 'parent_uuid' => $parentUuid, + 'category' => null, + 'description' => null, + 'items' => [ + 'choice' => $this->datalistItemPlain('choice', $groupUuid, $listName), + ], + ], + ], + ]; + + $imported = DirectorProperty::import($plain, $db); + $imported->store(); + foreach ($imported->fetchItemsFromDb() as $child) { + $child->store(); + foreach ($child->fetchItemsFromDb() as $grandchild) { + $grandchild->store(); + } + } + + $reloadedGroup = DirectorProperty::loadWithUniqueId(Uuid::fromString($groupUuid), $db); + $grandchildren = $reloadedGroup->fetchItemsFromDb(); + $this->assertCount(1, $grandchildren); + + $grandchildDatalist = $grandchildren[0]->getDatalist(); + $this->assertNotNull( + $grandchildDatalist, + 'Newly created datalist referenced by a dictionary grandchild must be persisted and linked' + ); + $this->assertEquals($listName, $grandchildDatalist->get('list_name')); + $this->assertNotNull($grandchildDatalist->get('uuid'), 'Newly created datalist must have a persisted uuid'); + } + public function testExportRoundTrip(): void { if ($this->skipForMissingDb()) { @@ -313,9 +432,20 @@ protected function tearDown(): void $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('director_property_datalist', $dba->quoteInto('property_uuid = ?', $quotedUuid)); + $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)); } @@ -330,6 +460,29 @@ protected function tearDown(): void 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; @@ -350,6 +503,25 @@ private function makeDatalist(string $listName, Db $db): DirectorDatalist 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. From 488bb23cff85351d2db05e9d2f7839e720b51262 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 11:44:21 +0200 Subject: [PATCH 055/221] Add CustomVariableValueValidator to check REST API variable values against value_type --- .../CustomVariableValueValidator.php | 108 +++++++++++++ .../CustomVariableValueValidatorTest.php | 145 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 library/Director/CustomVariable/CustomVariableValueValidator.php create mode 100644 test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php diff --git a/library/Director/CustomVariable/CustomVariableValueValidator.php b/library/Director/CustomVariable/CustomVariableValueValidator.php new file mode 100644 index 000000000..b83c777fd --- /dev/null +++ b/library/Director/CustomVariable/CustomVariableValueValidator.php @@ -0,0 +1,108 @@ +getDatalist(); + if ($datalist === null) { + return; + } + + foreach ($datalist->getEntries() as $entry) { + if ($entry->get('entry_name') === $value) { + return; + } + } + + throw new InvalidArgumentException(sprintf( + "'%s' is not a valid value for the custom variable '%s'", + (string) $value, + $key + )); + } +} diff --git a/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php b/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php new file mode 100644 index 000000000..6ffa265fc --- /dev/null +++ b/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php @@ -0,0 +1,145 @@ +expectException(InvalidArgumentException::class); + CustomVariableValueValidator::assertMatchesType('env', ['a', 'b'], '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) ['a' => 'b'], 'dynamic-array'); + } + + public function testArrayValueAcceptsList(): void + { + CustomVariableValueValidator::assertMatchesType('ssh_args', ['a', 'b'], 'fixed-array'); + $this->addToAssertionCount(1); + } + + public function testDictionaryValueRejectsList(): void + { + $this->expectException(InvalidArgumentException::class); + CustomVariableValueValidator::assertMatchesType('mysql', ['a', 'b'], 'dynamic-dictionary'); + } + + public function testDictionaryValueAcceptsObject(): void + { + CustomVariableValueValidator::assertMatchesType('mysql', (object) ['host' => 'db'], 'fixed-dictionary'); + $this->addToAssertionCount(1); + } + + 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); + } + + /** + * @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(); + } +} From 729573f05dfb2674e7dc356001f77412f651b1b6 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 11:44:26 +0200 Subject: [PATCH 056/221] Add CustomVariableValueApplier with an atomic transaction and value validation --- .../RestApi/CustomVariableValueApplier.php | 254 ++++++++++++++++++ .../CustomVariableValueApplierTest.php | 222 +++++++++++++++ 2 files changed, 476 insertions(+) create mode 100644 library/Director/RestApi/CustomVariableValueApplier.php create mode 100644 test/php/library/Director/RestApi/CustomVariableValueApplierTest.php diff --git a/library/Director/RestApi/CustomVariableValueApplier.php b/library/Director/RestApi/CustomVariableValueApplier.php new file mode 100644 index 000000000..0a30be178 --- /dev/null +++ b/library/Director/RestApi/CustomVariableValueApplier.php @@ -0,0 +1,254 @@ + value + * @param string $actionName Endpoint name + * @param string $method POST or PUT + * @param bool $replaceAll + * + * @throws NotFoundError + */ + public function apply( + IcingaObject $object, + array $overRiddenCustomVars, + string $actionName, + string $method, + bool $replaceAll + ): void { + $dbAdapter = $this->db->getDbAdapter(); + $type = $object->getShortTableName(); + $objectVars = $object->vars(); + $wipeInDb = $method === 'PUT' && $object->get('id'); + + if ($wipeInDb) { + $dbAdapter->beginTransaction(); + } + + try { + if ($wipeInDb) { + $objectWhere = $dbAdapter->quoteInto("{$type}_id = ?", $object->get('id')); + $dbAdapter->delete('icinga_' . $type . '_var', $objectWhere); + + $uuidExpr = DbUtil::quoteBinaryCompat( + DbUtil::binaryResult($object->get('uuid')), + $dbAdapter + ); + $dbAdapter->delete( + 'icinga_' . $type . '_property', + $dbAdapter->quoteInto("{$type}_uuid = ?", $uuidExpr) + ); + + $objectVars = new CustomVariables(); + } elseif ($replaceAll) { + $obsoleteKeys = []; + foreach ($objectVars as $key => $var) { + if (! array_key_exists($key, $overRiddenCustomVars)) { + $obsoleteKeys[] = $key; + } + } + + foreach ($obsoleteKeys as $key) { + unset($objectVars->$key); + } + } + + $customProperties = $this->getObjectCustomProperties($object); + + foreach ($overRiddenCustomVars as $key => $value) { + $this->applySingleVar( + $object, + $objectVars, + $customProperties, + $key, + $value, + $actionName, + $method, + $type + ); + } + + $objectVars->storeToDb($object); + } catch (Throwable $e) { + if ($wipeInDb) { + $dbAdapter->rollBack(); + } + + throw $e; + } + + if ($wipeInDb) { + $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( + IcingaObject $object, + CustomVariables $objectVars, + array $customProperties, + string $key, + mixed $value, + string $actionName, + string $method, + string $type + ): void { + $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 ($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 ($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 + ); + } + + $dbAdapter->insert('icinga_' . $type . '_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($customPropertyUuid, $dbAdapter), + $type . '_uuid' => DbUtil::quoteBinaryCompat($object->get('uuid'), $dbAdapter) + ]); + + $objectVars->registerVarUuid($key, Uuid::fromBytes($customPropertyUuid)); + } + + /** + * 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/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php b/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php new file mode 100644 index 000000000..5dddbf679 --- /dev/null +++ b/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php @@ -0,0 +1,222 @@ +skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $host = $this->createTemplate($db); + + (new CustomVariableValueApplier($db))->apply( + $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( + $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( + $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( + $host, + [self::ENV_KEY => 'production'], + 'variables', + 'PUT', + false + ); + + $host = IcingaHost::load(self::TEMPLATE_NAME, $db); + + (new CustomVariableValueApplier($db))->apply( + $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( + $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( + $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' + ); + } + + 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): 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 ([$stringProperty, $dictProperty] as $property) { + $dba->insert('icinga_host_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($property->get('uuid'), $dba), + '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(); + } +} From a6b264d78cfdc19e8ee14e839355746521d6026a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 11:46:02 +0200 Subject: [PATCH 057/221] IcingaObjectHandler: Delegate variable overrides to CustomVariableValueApplier, fix DELETE scoping and JSON body shape --- doc/70-REST-API.md | 7 +- .../Director/RestApi/IcingaObjectHandler.php | 188 ++++++------------ .../RestApi/IcingaObjectHandlerTest.php | 37 ++++ 3 files changed, 97 insertions(+), 135 deletions(-) create mode 100644 test/php/library/Director/RestApi/IcingaObjectHandlerTest.php diff --git a/doc/70-REST-API.md b/doc/70-REST-API.md index dc0c4e3a2..215e59bd1 100644 --- a/doc/70-REST-API.md +++ b/doc/70-REST-API.md @@ -257,10 +257,9 @@ body. * `POST` **merges**: only the keys you send are touched, all other existing variables on the object are left untouched. -* `PUT` **replaces**: every custom variable set directly on the object - is removed first, then the ones from the body are (re-)applied. Values - inherited from templates are not affected either way, since they - aren't stored on the object itself. +* `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. #### Setting a `string` variable diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index 1e9cf3151..d5020e046 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -7,18 +7,15 @@ use Icinga\Exception\NotFoundError; use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Core\CoreApi; -use Icinga\Module\Director\CustomVariable\CustomVariables; use Icinga\Module\Director\Data\Exporter; -use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\DirectorObject\Lookup\ServiceFinder; use Icinga\Module\Director\Exception\DuplicateKeyException; use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Resolver\OverrideHelper; use InvalidArgumentException; -use PDO; -use Ramsey\Uuid\Uuid; use RuntimeException; +use stdClass; class IcingaObjectHandler extends RequestHandler { @@ -73,6 +70,8 @@ protected function requireJsonBody() ); } + static::assertJsonBodyIsObject($data); + return $data; } @@ -81,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 { @@ -91,6 +129,9 @@ 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); } @@ -100,49 +141,6 @@ protected function processApiRequest() } } - /** - * Get the custom properties linked to the given object. - * - * @param IcingaObject $object - * - * @return array - */ - public function getObjectCustomProperties(IcingaObject $object): array - { - if ($object->get('uuid') === null) { - return []; - } - - $type = $object->getShortTableName(); - $db = $object->getConnection(); - $ids = $object->listAncestorIds(); - $ids[] = $object->get('id'); - $query = $db->getDbAdapter() - ->select() - ->from( - ['dp' => 'director_property'], - [ - 'key_name' => 'dp.key_name', - 'uuid' => 'dp.uuid', - 'value_type' => 'dp.value_type', - 'label' => 'dp.label' - ] - ) - ->join(['iop' => "icinga_$type" . '_property'], 'dp.uuid = iop.property_uuid', []) - ->join(['io' => "icinga_$type"], 'io.uuid = iop.' . $type . '_uuid', []) - ->where('io.id IN (?)', $ids) - ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) - ->order('key_name'); - - $result = []; - foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { - $row = DbUtil::normalizeRow($row); - $result[$row['key_name']] = $row; - } - - return $result; - } - protected function handleApiRequest() { $request = $this->request; @@ -165,6 +163,7 @@ protected function handleApiRequest() switch ($request->getMethod()) { case 'DELETE': + static::assertDeleteAllowed($this->request->getActionName()); $object = $this->requireObject(); $object->delete(); $this->sendJson($object->toPlainObject(false, true)); @@ -180,12 +179,14 @@ protected function handleApiRequest() $actionName = $this->request->getActionName(); $overRiddenCustomVars = []; + $replaceAll = false; if ($actionName === 'variables') { $overRiddenCustomVars = $data; } else { // Extract custom vars from the data if (isset($data['vars'])) { $overRiddenCustomVars = (array) $data['vars']; + $replaceAll = $request->getMethod() === 'POST'; unset($data['vars']); } @@ -242,96 +243,21 @@ protected function handleApiRequest() } } - if (empty($overRiddenCustomVars)) { + $isVariablesPut = $actionName === 'variables' && $request->getMethod() === 'PUT'; + if (empty($overRiddenCustomVars) && ! $isVariablesPut && ! $replaceAll) { $this->sendJson($object->toPlainObject(false, true)); break; } - $objectVars = $object->vars(); - if ($object->get('id') && $request->getMethod() === 'PUT') { - $objectWhere = $db->getDbAdapter()->quoteInto("{$type}_id = ?", $object->get('id')); - $dbAdapter = $db->getDbAdapter(); - $dbAdapter->beginTransaction(); - $dbAdapter->delete( - 'icinga_' . $type . '_var', - $objectWhere - ); - - $uuidExpr = DbUtil::quoteBinaryCompat( - DbUtil::binaryResult($object->get('uuid')), - $dbAdapter - ); - $dbAdapter->delete( - 'icinga_' . $type . '_property', - $dbAdapter->quoteInto( - "{$type}_uuid = ?", - $uuidExpr - ) - ); - - $dbAdapter->commit(); - - $objectVars = new CustomVariables(); - } - - $customProperties = $this->getObjectCustomProperties($object); - - foreach ($overRiddenCustomVars as $key => $value) { - $objectVars->set($key, $value); - $objectVars->get($key)->setModified(); - if (isset($customProperties[$key])) { - $objectVars->registerVarUuid($key, Uuid::fromBytes($customProperties[$key]['uuid'])); - - continue; - } - - if ($actionName !== 'variables') { - continue; - } - - 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->getMethod() === 'POST') { - $errMsg = sprintf( - 'The custom variable %s should be first added to the template', - $key - ); - - throw new NotFoundError($errMsg); - } - - $query = $db->getDbAdapter() - ->select() - ->from(['dp' => 'director_property'], ['uuid']) - ->where('dp.key_name = ? AND dp.parent_uuid IS NULL', $key); - $customPropertyUuid = DbUtil::binaryResult($db->getDbAdapter()->fetchOne($query)); - - if (! $customPropertyUuid) { - throw new NotFoundError(sprintf( - "'%s' is not configured in Icinga Director as a custom variable", - $key - )); - } - - $db->getDbAdapter()->insert( - 'icinga_' . $type . '_property', - [ - 'property_uuid' => DbUtil::quoteBinaryCompat($customPropertyUuid, $db->getDbAdapter()), - $type . '_uuid' => DbUtil::quoteBinaryCompat($object->get('uuid'), $db->getDbAdapter()) - ] - ); - - $objectVars->registerVarUuid($key, Uuid::fromBytes($customPropertyUuid)); - } + (new CustomVariableValueApplier($db))->apply( + $object, + $overRiddenCustomVars, + $actionName, + $request->getMethod(), + $replaceAll + ); - $objectVars->storeToDb($object); $object = IcingaObject::loadByType($type, $object->getObjectName(), $db); $this->sendJson($object->toPlainObject(false, true)); diff --git a/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php b/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php new file mode 100644 index 000000000..e77e21b9a --- /dev/null +++ b/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php @@ -0,0 +1,37 @@ +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]); + } +} From af39075b5f972e0f27afa255ecb17aa032e6ef1d Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 11:57:32 +0200 Subject: [PATCH 058/221] CustomVariablesForm: Allow attaching a new custom variable only for templates --- application/forms/CustomVariablesForm.php | 22 +++++++++++++ .../Director/Form/CustomVariablesFormTest.php | 32 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index f78719549..9b2571e95 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -2,6 +2,7 @@ namespace Icinga\Module\Director\Forms; +use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\Forms\DictionaryElements\Dictionary; @@ -291,6 +292,26 @@ function ($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 ProgrammingError + */ + private function assertCanAttachNewVariable(): void + { + if (! $this->object->isTemplate()) { + throw new ProgrammingError( + 'Custom Variables can only be attached directly to a template, got %s', + $this->object->getObjectName() + ); + } + } + protected function onSuccess(): void { $vars = $this->object->vars(); @@ -340,6 +361,7 @@ protected function onSuccess(): void } if (isset($property['new'])) { + $this->assertCanAttachNewVariable(); $this->varsHasBeenModified = true; $this->object->getConnection()->insert( "icinga_$type" . '_property', diff --git a/test/php/library/Director/Form/CustomVariablesFormTest.php b/test/php/library/Director/Form/CustomVariablesFormTest.php index c9bbc93d9..781818ca5 100644 --- a/test/php/library/Director/Form/CustomVariablesFormTest.php +++ b/test/php/library/Director/Form/CustomVariablesFormTest.php @@ -5,11 +5,43 @@ namespace Tests\Icinga\Module\Director\Form; +use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Forms\CustomVariablesForm; +use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Test\BaseTestCase; +use ReflectionMethod; class CustomVariablesFormTest extends BaseTestCase { + public function testAttachingNewPropertyToNonTemplateThrows(): void + { + $host = IcingaHost::create([ + 'object_name' => 'apitest', + 'object_type' => 'object', + ]); + $form = new CustomVariablesForm($host); + $method = new ReflectionMethod($form, 'assertCanAttachNewVariable'); + $method->setAccessible(true); + + $this->expectException(ProgrammingError::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 testFiltersEmptyStrings(): void { $result = CustomVariablesForm::filterEmpty(['ssl_verify' => '', 'http_address' => 'monitor.example.com']); From b8b994ebed403fce04a7c493a77ae1e604890dc3 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 11:57:38 +0200 Subject: [PATCH 059/221] BasketSnapshotCustomVariableResolver: Enforce storeNewProperties() before relinking custom properties --- .../BasketSnapshotCustomVariableResolver.php | 27 +++++++++++ .../BasketSnapshotCustomVariableTest.php | 48 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php index 4a21b09dd..9f0122c92 100644 --- a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php +++ b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php @@ -2,6 +2,7 @@ namespace Icinga\Module\Director\DirectorObject\Automation; +use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Data\Db\DbConnection; use Icinga\Module\Director\Db; use Icinga\Module\Director\Db\DbUtil; @@ -30,6 +31,9 @@ class BasketSnapshotCustomVariableResolver /** @var DirectorProperty[]|null */ protected $targetProperties; + /** @var bool */ + protected $newPropertiesStored = false; + public function __construct($objects, Db $targetDb) { $this->objects = (array) $objects; @@ -76,6 +80,8 @@ public function storeNewProperties(): void )->toString(); } } + + $this->newPropertiesStored = true; } /** @@ -92,6 +98,8 @@ public function relinkObjectCustomProperties(IcingaObject $new, $object): void return; } + $this->assertPropertiesHaveBeenStored(); + $customPropertyMap = $this->getUuidMap(); $db = $this->targetDb->getDbAdapter(); $objectUuid = DbUtil::quoteBinaryCompat($new->get('uuid'), $db); @@ -217,6 +225,25 @@ protected function getRequiredUuids(): array 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. * diff --git a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php index 87f310753..c6e948eab 100644 --- a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php +++ b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php @@ -5,9 +5,11 @@ 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; @@ -176,6 +178,52 @@ public function testRestoreIsIdempotent(): void $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()) { From 07d8a2a5e37663cce9373aded003bd2fa20cf855 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 15:47:34 +0200 Subject: [PATCH 060/221] module.js: Fix highlightActiveDashlet always returning before highlighting --- public/js/module.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/module.js b/public/js/module.js index 2d60fc35a..d0f6323f2 100644 --- a/public/js/module.js +++ b/public/js/module.js @@ -735,7 +735,7 @@ url = $container.data('icingaUrl'); $actions = $('.main-actions', $('#col1')); } - if ($actions) { + if (! $actions || ! $actions.length) { return; } From 2912272b506b6ae74f231fed8ea39fb5e7daf644 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 15:48:49 +0200 Subject: [PATCH 061/221] DictionaryItem: Fix nested dictionary label placement and missing item type crash --- application/forms/DictionaryElements/DictionaryItem.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index b6af11671..1627b29a1 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -41,7 +41,7 @@ public function __construct(string $name, array $items, $attributes = null) parent::__construct($name, $attributes); } - private static function fetchItemType(UuidInterface $uuid): string + private static function fetchItemType(UuidInterface $uuid): ?string { $db = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); $query = $db->select() @@ -51,7 +51,9 @@ private static function fetchItemType(UuidInterface $uuid): string ) ->where('dp.parent_uuid = ?', Db\DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); - return $db->fetchOne($query); + $itemType = $db->fetchOne($query); + + return $itemType === false ? null : $itemType; } /** From 8933fbfdcf763c9d39434655915fd2f4ebd80c87 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 16:09:47 +0200 Subject: [PATCH 062/221] CustomVariablesForm: Stop treating 0, 0.0 and the string 0 as unset --- application/forms/CustomVariablesForm.php | 18 +++++- .../Director/Form/CustomVariablesFormTest.php | 56 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index 9b2571e95..a02a8607b 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -312,6 +312,22 @@ private function assertCanAttachNewVariable(): void } } + /** + * Whether the given value should be treated as unset + * + * @param mixed $value + * + * @return bool + */ + private 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 { $vars = $this->object->vars(); @@ -372,7 +388,7 @@ protected function onSuccess(): void ); } - if (! is_bool($value) && empty($value)) { + if (self::isValueUnset($value)) { $vars->set($key, null); } else { $vars->set($key, $value); diff --git a/test/php/library/Director/Form/CustomVariablesFormTest.php b/test/php/library/Director/Form/CustomVariablesFormTest.php index 781818ca5..6dd30ef6f 100644 --- a/test/php/library/Director/Form/CustomVariablesFormTest.php +++ b/test/php/library/Director/Form/CustomVariablesFormTest.php @@ -42,6 +42,62 @@ public function testAttachingNewPropertyToTemplateIsAllowed(): void $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 testFiltersEmptyStrings(): void { $result = CustomVariablesForm::filterEmpty(['ssl_verify' => '', 'http_address' => 'monitor.example.com']); From 9839333081caa0ad4e48bdc265d1cbcc393169a9 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 16:16:22 +0200 Subject: [PATCH 063/221] ObjectController: Show every nested key in the apply for hint table, not just the last one --- library/Director/Web/Controller/ObjectController.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index 9cc660b8d..d99aac595 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -792,13 +792,10 @@ private function prepareApplyForHeader(): void $nestedConfig = $config . '.' . $nestedKeyAttributes['key_name'] . '$'; } - $nestedContent[] = new HtmlElement('div', null, Text::create($nestedConfig)); $nestedKeyName = $nestedKeyAttributes['key_name']; $nestedLabel = $nestedKeyAttributes['label'] ?? $nestedKeyAttributes['key_name']; - $nestedContent = [ - $this->createKey($nestedKeyName, $nestedLabel), - $this->createValue($nestedConfig) - ]; + $nestedContent[] = $this->createKey($nestedKeyName, $nestedLabel); + $nestedContent[] = $this->createValue($nestedConfig); } if (preg_match('/[^a-zA-Z0-9_]/', $keyAttributes['key_name'])) { From 0e21c4077f7e26af7c874199e206754b9d9573a7 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 16:21:29 +0200 Subject: [PATCH 064/221] Dictionary, NestedDictionary: Remove dead item removal bookkeeping --- application/forms/DictionaryElements/Dictionary.php | 2 -- .../forms/DictionaryElements/NestedDictionary.php | 10 ---------- 2 files changed, 12 deletions(-) diff --git a/application/forms/DictionaryElements/Dictionary.php b/application/forms/DictionaryElements/Dictionary.php index 72746af34..624876242 100644 --- a/application/forms/DictionaryElements/Dictionary.php +++ b/application/forms/DictionaryElements/Dictionary.php @@ -88,8 +88,6 @@ protected function assemble(): void $clearedItemName = $removedValue['name']; if (isset($this->items[$clearedItemName])) { $removedItems[$clearedItemName] = true; - } elseif (isset($this->items[$clearedItemName]['new'])) { - unset($this->items[$clearedItemName]); } } diff --git a/application/forms/DictionaryElements/NestedDictionary.php b/application/forms/DictionaryElements/NestedDictionary.php index e46b87f01..24ae859a6 100644 --- a/application/forms/DictionaryElements/NestedDictionary.php +++ b/application/forms/DictionaryElements/NestedDictionary.php @@ -89,22 +89,12 @@ protected function assemble(): void $this->registerElement($remove); if ($remove->hasBeenPressed()) { - $removedValue = $this->getPopulatedValue($count); - $clearedId = null; - if (isset($removedValue['id'])) { - $clearedId = $removedValue['id']; - } - $this->clearPopulatedValue($remove->getName()); $this->clearPopulatedValue($count); // Re-index populated values to ensure proper association with form data foreach (range($count + 1, $expectedCount) as $i) { - $newPopulatedValue = $this->getPopulatedValue($count); - $newId = $newPopulatedValue['id'] ?? null; - $newPopulatedValue['id'] = $clearedId; $this->populate([$i - 1 => $this->getPopulatedValue($i) ?? []]); - $clearedId = $newId; } } else { $newCount++; From 4aa35a4fda9ad2fbd83ff65097f54a4cbe28de24 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 16:26:04 +0200 Subject: [PATCH 065/221] CustomVariablesForm: Fail fast when a service variable override has no host set --- application/forms/CustomVariablesForm.php | 18 +++++++ .../Director/Form/CustomVariablesFormTest.php | 53 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index a02a8607b..aaf3528fb 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -204,6 +204,22 @@ public function isOverrideServiceVars(): bool || ($this->host && $this->set); } + /** + * Assert that a host has been set whenever an override is requested + * + * @return void + * + * @throws ProgrammingError + */ + private function assertOverrideHostIsSet(): void + { + if ($this->isOverrideServiceVars() && $this->host === null) { + throw new ProgrammingError( + 'CustomVariablesForm needs setHostForService() to be called before overriding service variables' + ); + } + } + public function hasBeenSubmitted(): bool { $pressedButton = $this->getPressedSubmitElement(); @@ -330,6 +346,8 @@ private static function isValueUnset(mixed $value): bool protected function onSuccess(): void { + $this->assertOverrideHostIsSet(); + $vars = $this->object->vars(); /** @var Dictionary $propertiesElement */ diff --git a/test/php/library/Director/Form/CustomVariablesFormTest.php b/test/php/library/Director/Form/CustomVariablesFormTest.php index 6dd30ef6f..917b04c8c 100644 --- a/test/php/library/Director/Form/CustomVariablesFormTest.php +++ b/test/php/library/Director/Form/CustomVariablesFormTest.php @@ -8,6 +8,7 @@ use Icinga\Exception\ProgrammingError; 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 ReflectionMethod; @@ -98,6 +99,58 @@ public function testIsValueUnsetTreatsEmptyArrayAsUnset(): void $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(ProgrammingError::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']); From 8d0970c8b11d640e80a30bf047aa1dff9804eec0 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 7 Jul 2026 16:43:05 +0200 Subject: [PATCH 066/221] ObjectController: Provide a better note for accessing dictionary values in apply-for-rule --- library/Director/Web/Controller/ObjectController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index d99aac595..589148a47 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -734,7 +734,7 @@ private function prepareApplyForHeader(): void Text::create(sprintf( $this->translate( 'The values of selected host variable for apply-for-rule' - . ' is accessible through %s and keys through %s.' + . ' are accessible through %s and keys through %s.' ), '$value$', '$key$' @@ -850,8 +850,8 @@ private function prepareApplyForHeader(): void Attributes::create(['class' => ['apply-for-header-content']]), [ Text::create($this->translate( - 'Nested keys of selected host dictionary variable for apply-for-rule' - . ' are accessible through value as shown in the table below:' + '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 ] From e3b4de7875cdebfbfcf3262fec4023263b25b420 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 10:36:24 +0200 Subject: [PATCH 067/221] DirectorProperty: Fetch the datalist linked to the property before deleting it --- library/Director/Objects/DirectorProperty.php | 6 +++-- .../Director/Objects/DirectorPropertyTest.php | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index 499accabb..156bdf332 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -389,17 +389,19 @@ 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 ($this->getDatalist()) { + if ($datalist) { $db->insert( 'director_property_datalist', [ 'property_uuid' => $propertyUuid, - 'list_uuid' => Db\DbUtil::quoteBinaryCompat($this->datalist->get('uuid'), $db), + 'list_uuid' => Db\DbUtil::quoteBinaryCompat($datalist->get('uuid'), $db), ] ); } diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 94031feda..3cfe0fb90 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -191,6 +191,32 @@ public function testDatalistStrictExportIncludesDatalistName(): void $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()) { From 494f06eb968071b702e31583925936df3d6891f1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 11:17:04 +0200 Subject: [PATCH 068/221] DirectorPropertyi: Load :datalist only if it is present in DB --- library/Director/Objects/DirectorProperty.php | 5 ++++- .../Director/Objects/DirectorPropertyTest.php | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index 156bdf332..5832edb74 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -239,7 +239,10 @@ public function getDatalist(): ?DirectorDatalist 'dpdl.property_uuid = ?', Db\DbUtil::quoteBinaryCompat($this->get('uuid'), $this->db) )); - $this->datalist = DirectorDatalist::load($this->db->fetchOne($query), $this->connection); + $listName = $this->db->fetchOne($query); + if ($listName) { + $this->datalist = DirectorDatalist::load($listName, $this->connection); + } } return $this->datalist; diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 3cfe0fb90..7e383e504 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -44,6 +44,21 @@ public function testStringPropertyPersistsAndReloads(): void $this->assertEquals('Environment', $loaded->get('label')); } + 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()) { From 1b377981269995475bdcba291083c743de5a3bc3 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 11:58:57 +0200 Subject: [PATCH 069/221] MigrateCommand: Link datalist after migrating datafield to new custom variable --- application/clicommands/MigrateCommand.php | 21 ++++++ .../Director/Objects/MigrateCommandTest.php | 68 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index c5b0e5e54..9cdf43af9 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -11,6 +11,7 @@ use Icinga\Module\Director\Db\DbSelectParenthesis; use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Objects\DirectorProperty; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; @@ -173,6 +174,13 @@ private function prepareCustomProperties(): array $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"; } @@ -213,6 +221,12 @@ private function migrateDatafields(array $customProperties, bool $dryRun): void unset($customProperty['item_type']); } + $datalistUuidBytes = null; + if (isset($customProperty['datalist_uuid'])) { + $datalistUuidBytes = $customProperty['datalist_uuid']; + unset($customProperty['datalist_uuid']); + } + $this->migratedDataFields[$customProperty['datafield_id']] = $varName; if (! $dryRun) { $datafieldId = $customProperty['datafield_id']; @@ -233,6 +247,13 @@ private function migrateDatafields(array $customProperties, bool $dryRun): void ]); } + if ($datalistUuidBytes !== null) { + $db->insert('director_property_datalist', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($uuidBytes, $dbAdapter), + 'list_uuid' => DbUtil::quoteBinaryCompat($datalistUuidBytes, $dbAdapter) + ]); + } + $this->migrateDatafieldObjectTemplateBinding($datafieldId, $propertyUuid); } diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index d2e7debab..d4c0a1a2f 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -176,6 +176,74 @@ public function testDatalistNonStrictMigratesCorrectly(): void $this->assertEquals('datalist-non-strict', $row->value_type); } + 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 testDatalistNonStrictMigrationLinksDatalist(): 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_SUGGEST) + ); + $this->assertNotFalse($property, 'env_suggest 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-non-strict datafield must link the new property to its datalist' + ); + } + public function testDeleteOptionRemovesMigratedDatafields(): void { if ($this->skipForMissingDb()) { From a517653b9eae20a5d94393eb070dd556b7d48268 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 12:02:58 +0200 Subject: [PATCH 070/221] CustomVariableForm: Allow dynamic-array as grand child for dynamic dictionary --- application/forms/CustomVariableForm.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index 303307902..24c4747a3 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -182,6 +182,7 @@ protected function assemble(): void 'string' => 'String', 'number' => 'Number', 'bool' => 'Boolean', + 'dynamic-array' => 'Dynamic Array', 'datalist-strict' => 'Data List Strict', 'datalist-non-strict' => 'Data List Non Strict', ]; @@ -189,7 +190,6 @@ protected function assemble(): void if (! $this->isNestedField) { $types += [ 'fixed-array' => 'Fixed Array', - 'dynamic-array' => 'Dynamic Array', 'fixed-dictionary' => 'Fixed Dictionary' ]; From ea2e02b4cbf5ce54c321e1ebcc3a96ffa07c4b92 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 12:05:28 +0200 Subject: [PATCH 071/221] DeleteCustomVariableForm: Relink datalist after updating item order for fixed-array --- .../forms/DeleteCustomVariableForm.php | 28 +++- .../Form/DeleteCustomVariableFormTest.php | 139 +++++++++++++++++- 2 files changed, 160 insertions(+), 7 deletions(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index 6c6a84a62..77f185fce 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -512,12 +512,21 @@ private function updateFixedArrayItems(UuidInterface $uuid): void 'parent_uuid', 'value_type', 'label', - 'description' + 'description', + 'category_id' ]) - ->where('parent_uuid = ?', $quotedUuid); + ->joinLeft( + ['dpdl' => 'director_property_datalist'], + 'dpdl.property_uuid = dp.uuid', + ['list_uuid'] + ) + ->where('dp.parent_uuid = ?', $quotedUuid); $propItems = array_map([DbUtil::class, 'normalizeRow'], $db->fetchAll($query, [], Zend_Db::FETCH_ASSOC)); + // Deleting director_property here cascades away any director_property_datalist link + // for these items (ON DELETE CASCADE on property_uuid) — list_uuid was captured above + // so each item's link can be reinserted below, after the uuid exists again. $db->delete( 'director_property', ['parent_uuid = ?' => $quotedUuid] @@ -525,15 +534,24 @@ private function updateFixedArrayItems(UuidInterface $uuid): void $count = 0; foreach ($propItems as $propItem) { + $itemUuid = Uuid::fromBytes($propItem['uuid'])->getBytes(); $this->db->insert('director_property', [ - 'uuid' => Uuid::fromBytes($propItem['uuid'])->getBytes(), - 'parent_uuid' => $uuid->getBytes(), + 'uuid' => DbUtil::quoteBinaryCompat($itemUuid, $db), + 'parent_uuid' => $quotedUuid, 'key_name' => $count, 'label' => $propItem['label'], 'value_type' => $propItem['value_type'], - 'description' => $propItem['description'] + 'description' => $propItem['description'], + 'category_id' => $propItem['category_id'] ]); + if ($propItem['list_uuid'] !== null) { + $this->db->insert('director_property_datalist', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($itemUuid, $db), + 'list_uuid' => DbUtil::quoteBinaryCompat($propItem['list_uuid'], $db) + ]); + } + $count++; } } diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index 6c5c89b08..a9d665c03 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -7,6 +7,7 @@ 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\Test\BaseTestCase; @@ -156,22 +157,154 @@ public function testDeleteDatalistPropertyRemovesDatalistLink(): void $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 tearDown(): void { if ($this->hasDb()) { $dba = $this->getDb()->getDbAdapter(); - foreach (['___TEST___environment', '___TEST___snmp_v3', '___TEST___escalation_tier'] as $keyName) { + $keyNames = [ + '___TEST___environment', + '___TEST___snmp_v3', + '___TEST___escalation_tier', + '___TEST___fixed_array_parent', + ]; + 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. + $rowUuid = DbUtil::binaryResult($row->uuid); + $childUuids = array_map( + [DbUtil::class, 'binaryResult'], + $dba->fetchCol( + $dba->select()->from('director_property', ['uuid'])->where( + 'parent_uuid = ?', + DbUtil::quoteBinaryCompat($rowUuid, $dba) + ) + ) + ); + foreach ($childUuids as $childUuid) { + $dba->delete( + 'director_property_datalist', + $dba->quoteInto( + 'property_uuid = ?', + DbUtil::quoteBinaryCompat($childUuid, $dba) + ) + ); + } + $dba->delete( + 'director_property_datalist', + $dba->quoteInto( + 'property_uuid = ?', + DbUtil::quoteBinaryCompat($rowUuid, $dba) + ) + ); $dba->delete( 'director_property', $dba->quoteInto( 'parent_uuid = ?', - DbUtil::quoteBinaryCompat(DbUtil::binaryResult($row->uuid), $dba) + DbUtil::quoteBinaryCompat($rowUuid, $dba) ) ); $dba->delete('director_property', $dba->quoteInto('key_name = ?', $keyName)); @@ -179,6 +312,8 @@ public function tearDown(): void } $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(); From b09289b87774e2eb6fb2ddfb510f11507f2fd33c Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 12:59:27 +0200 Subject: [PATCH 072/221] ObjectTabs: Custom Variables tab should appear before Fields tab --- library/Director/Web/Tabs/ObjectTabs.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/library/Director/Web/Tabs/ObjectTabs.php b/library/Director/Web/Tabs/ObjectTabs.php index 74606d07c..a15e2b5f5 100644 --- a/library/Director/Web/Tabs/ObjectTabs.php +++ b/library/Director/Web/Tabs/ObjectTabs.php @@ -95,14 +95,6 @@ protected function addTabsForExistingObject() )); } - if ($auth->hasPermission(Permission::ADMIN) && $this->hasFields()) { - $this->add('fields', array( - 'url' => sprintf('director/%s/fields', $type), - 'urlParams' => $params, - 'label' => $this->translate('Fields (Deprecated)') - )); - } - if ($auth->hasPermission(Permission::ADMIN) && $this->hasCustomProperties()) { $this->add('variables', array( 'url' => sprintf('director/%s/variables', $type), @@ -111,6 +103,14 @@ protected function addTabsForExistingObject() )); } + if ($auth->hasPermission(Permission::ADMIN) && $this->hasFields()) { + $this->add('fields', array( + 'url' => sprintf('director/%s/fields', $type), + 'urlParams' => $params, + 'label' => $this->translate('Fields (Deprecated)') + )); + } + // TODO: remove table check once we resolve all group types if ( $object->isGroup() && From 51b278b8f3d68712651a804b9e63e937f9775643 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 13:23:21 +0200 Subject: [PATCH 073/221] DeleteCustomVariableForm: Fix reindexing of items for fixed-array on item deletion --- .../forms/DeleteCustomVariableForm.php | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index 77f185fce..c1d7c0c0f 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -218,7 +218,7 @@ private function removeDictionaryItem(array &$item, array $path): void if (empty($path)) { unset($item[$key]); - } elseif (is_array($item[$key])) { + } elseif (isset($item[$key]) && is_array($item[$key])) { $this->removeDictionaryItem($item[$key], $path); } @@ -412,6 +412,8 @@ private function removeObjectCustomVars(array $property, ?array $parent = null): } $db = $this->db->getDbAdapter(); + $parentUuid = Uuid::fromBytes($parent['uuid']); + $rootUuid = null; if ($parent['parent_uuid'] !== null) { // Parent is itself a field — grandparent is the root property @@ -419,7 +421,7 @@ private function removeObjectCustomVars(array $property, ?array $parent = null): $rootProp = $this->fetchProperty($rootUuid); $rootType = $rootProp['value_type']; } else { - $rootUuid = Uuid::fromBytes($parent['uuid']); + $rootUuid = $parentUuid; $rootType = $parent['value_type']; } @@ -433,9 +435,7 @@ private function removeObjectCustomVars(array $property, ?array $parent = null): $isParentFixedArray = $parent['value_type'] === 'fixed-array'; $isRootFixedArray = $rootType === 'fixed-array'; if ($isParentFixedArray) { - $this->updateFixedArrayItems(Uuid::fromBytes($parent['uuid'])); - } elseif ($isRootFixedArray) { - $this->updateFixedArrayItems($rootUuid); + $this->updateFixedArrayItems($parentUuid, $property['key_name']); } foreach (['host', 'service', 'notification', 'command', 'user'] as $objectType) { @@ -458,13 +458,13 @@ private function removeObjectCustomVars(array $property, ?array $parent = null): $this->removeDictionaryItem($varValue, $path); } else { foreach ($varValue as $entryKey => $entryValue) { - if (! is_array($varValue[$entryKey])) { + if (! is_array($entryValue)) { continue; } - $this->removeDictionaryItem($varValue[$entryKey], $path); + $this->removeDictionaryItem($entryValue, $path); - if ($varValue[$entryKey] === []) { + if ($entryValue === []) { $varValue[$entryKey] = (object) []; } } @@ -480,7 +480,7 @@ private function removeObjectCustomVars(array $property, ?array $parent = null): continue; } - if ($isParentFixedArray) { + if ($isParentFixedArray && $rootUuid->equals($parentUuid)) { $varValue[$parent['key_name']] = array_values($varValue[$parent['key_name']]); } elseif ($isRootFixedArray) { $varValue = array_values($varValue); @@ -496,10 +496,11 @@ private function removeObjectCustomVars(array $property, ?array $parent = null): * Update the items for the given fixed array * * @param UuidInterface $uuid + * @param string $propertyIndex * * @return void */ - private function updateFixedArrayItems(UuidInterface $uuid): void + private function updateFixedArrayItems(UuidInterface $uuid, string $propertyIndex): void { $db = $this->db->getDbAdapter(); $quotedUuid = DbUtil::quoteBinaryCompat($uuid->getBytes(), $db); @@ -520,7 +521,9 @@ private function updateFixedArrayItems(UuidInterface $uuid): void 'dpdl.property_uuid = dp.uuid', ['list_uuid'] ) - ->where('dp.parent_uuid = ?', $quotedUuid); + ->where('dp.parent_uuid = ?', $quotedUuid) + ->where('dp.key_name != ?', $propertyIndex) + ->order('dp.key_name'); $propItems = array_map([DbUtil::class, 'normalizeRow'], $db->fetchAll($query, [], Zend_Db::FETCH_ASSOC)); From 8e3f09c97d878c256ae885a4fcf39bef84bb3ec8 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 17:20:03 +0200 Subject: [PATCH 074/221] Db: Roll back runFailSafeTransaction() on any Throwable, not just Exception TypeError extends Error, not Exception, so a TypeError thrown inside the wrapped callable previously skipped the rollback entirely, leaving the transaction open. This helper is shared by ImportExport, BranchMerger, and DbObjectStore in addition to DeleteCustomVariableForm. --- library/Director/Db.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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; From 63cf1d746c7a52f6f47dc5a9d48c98e7a0aba933 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 17:39:42 +0200 Subject: [PATCH 075/221] CustomvarController: Close the modal if the property was already deleted --- application/controllers/CustomvarController.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/application/controllers/CustomvarController.php b/application/controllers/CustomvarController.php index 40671d22b..779ff6e89 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -338,6 +338,10 @@ 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'])); From d899e60023395c964cf5b66d33151f45f8abd1a4 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 18:09:15 +0200 Subject: [PATCH 076/221] DeleteCustomVariableForm: Fix crash when deleting an item from a root-level fixed array Deleting an item from a fixed-array that is itself the root custom variable indexed into the stored value using the parent's key_name, but the stored value for a root-level fixed array is the bare list itself, not a dictionary keyed by that name. This threw when the update ran. --- .../forms/DeleteCustomVariableForm.php | 3 +- .../Form/DeleteCustomVariableFormTest.php | 93 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index c1d7c0c0f..d9edace97 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -481,7 +481,8 @@ private function removeObjectCustomVars(array $property, ?array $parent = null): } if ($isParentFixedArray && $rootUuid->equals($parentUuid)) { - $varValue[$parent['key_name']] = array_values($varValue[$parent['key_name']]); + // The fixed array is the root: the stored value is the bare list itself. + $varValue = array_values($varValue); } elseif ($isRootFixedArray) { $varValue = array_values($varValue); } diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index a9d665c03..0ed798026 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -10,6 +10,7 @@ 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\Test\BaseTestCase; use Ramsey\Uuid\Uuid; @@ -255,15 +256,107 @@ public function testUpdateFixedArrayItemsPreservesCategoryAndDatalistLink(): voi $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 tearDown(): void { if ($this->hasDb()) { $dba = $this->getDb()->getDbAdapter(); + // 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', + ]; + 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', ]; foreach ($keyNames as $keyName) { $rows = $dba->fetchAll( From 5d5b03941f333e574734c9941bf378654f1d878e Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 18:10:41 +0200 Subject: [PATCH 077/221] DeleteCustomVariableForm: Reindex fixed-array items numerically, not lexicographically key_name is a varchar column, so ordering the reindex query by it sorted '10' right after '1' and before '2' once a fixed array had more than ten items, scrambling the remaining items' order after a deletion. --- .../forms/DeleteCustomVariableForm.php | 6 +- .../Form/DeleteCustomVariableFormTest.php | 83 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index d9edace97..a1695f945 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -523,10 +523,12 @@ private function updateFixedArrayItems(UuidInterface $uuid, string $propertyInde ['list_uuid'] ) ->where('dp.parent_uuid = ?', $quotedUuid) - ->where('dp.key_name != ?', $propertyIndex) - ->order('dp.key_name'); + ->where('dp.key_name != ?', $propertyIndex); $propItems = array_map([DbUtil::class, 'normalizeRow'], $db->fetchAll($query, [], Zend_Db::FETCH_ASSOC)); + // key_name is a varchar column; a fixed array's item indexes must be sorted numerically + // here, not lexicographically (otherwise '10' would sort before '2'). + usort($propItems, fn($a, $b) => (int) $a['key_name'] <=> (int) $b['key_name']); // Deleting director_property here cascades away any director_property_datalist link // for these items (ON DELETE CASCADE on property_uuid) — list_uuid was captured above diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index 0ed798026..b8463026e 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -339,6 +339,88 @@ public function testDeletingRootFixedArrayItemUpdatesHostVarWithoutCrashing(): v ); } + 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 tearDown(): void { if ($this->hasDb()) { @@ -357,6 +439,7 @@ public function tearDown(): void '___TEST___escalation_tier', '___TEST___fixed_array_parent', '___TEST___backup_directories', + '___TEST___escalation_contacts', ]; foreach ($keyNames as $keyName) { $rows = $dba->fetchAll( From 164d70511a70c694740e19b48b552fb159043917 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 18:15:39 +0200 Subject: [PATCH 078/221] DeleteCustomVariableForm: Roll back the deletion if it fails partway through onSuccess() deleted datalist links, object vars, and the property row across several separate statements with no transaction, so a failure partway through (e.g. a malformed stored value) left the database in a half-deleted state. Wraps the whole thing in Db::runFailSafeTransaction(). --- .../forms/DeleteCustomVariableForm.php | 26 +++--- .../Form/DeleteCustomVariableFormTest.php | 85 +++++++++++++++++++ 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index a1695f945..4255d359d 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -3,8 +3,8 @@ namespace Icinga\Module\Director\Forms; use Icinga\Data\Filter\Filter; -use Icinga\Module\Director\Data\Db\DbConnection; use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Db; use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\Web\Widget\CustomVarObjectList; use Icinga\Web\Session; @@ -33,7 +33,7 @@ class DeleteCustomVariableForm extends CompatForm private $isNestedField = false; public function __construct( - protected DbConnection $db, + protected Db $db, protected array $property, protected array $parent = [] ) { @@ -233,8 +233,6 @@ protected function onSuccess(): void $uuid = $this->property['uuid']; $quotedUuid = DbUtil::quoteBinaryCompat($uuid, $this->db->getDbAdapter()); $db = $this->db; - - $db->getDbAdapter()->beginTransaction(); $prop = $this->property; // A dictionary can be nested arbitrarily deep (dictionary -> dictionary -> ...), @@ -244,19 +242,19 @@ protected function onSuccess(): void $allUuids = array_merge([$uuid], $this->collectDescendantUuids($uuid)); $quotedAllUuids = DbUtil::quoteBinaryCompat($allUuids, $this->db->getDbAdapter()); - $db->delete('director_property_datalist', Filter::where('property_uuid', $quotedAllUuids)); - - $this->removeObjectCustomVars($prop, $this->parent); - $this->removeFromOverrideServiceVars($prop, $this->parent); + $db->runFailSafeTransaction(function () use ($db, $prop, $quotedUuid, $quotedAllUuids) { + $db->delete('director_property_datalist', Filter::where('property_uuid', $quotedAllUuids)); - $objects = ['host', 'service', 'notification', 'command', 'user', 'service_set']; - foreach ($objects as $object) { - $this->db->delete("icinga_{$object}_var", Filter::where('property_uuid', $quotedUuid)); - } + $this->removeObjectCustomVars($prop, $this->parent); + $this->removeFromOverrideServiceVars($prop, $this->parent); - $db->delete('director_property', Filter::where('uuid', $quotedAllUuids)); + $objects = ['host', 'service', 'notification', 'command', 'user', 'service_set']; + foreach ($objects as $object) { + $this->db->delete("icinga_{$object}_var", Filter::where('property_uuid', $quotedUuid)); + } - $db->getDbAdapter()->commit(); + $db->delete('director_property', Filter::where('uuid', $quotedAllUuids)); + }); } /** diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index b8463026e..655f4dd30 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -421,14 +421,98 @@ public function testUpdateFixedArrayItemsReindexesNumericallyNotLexicographicall ); } + 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 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', ]; foreach ($hostNames as $hostName) { $dba->delete('icinga_host', ['object_name = ?' => $hostName]); @@ -440,6 +524,7 @@ public function tearDown(): void '___TEST___fixed_array_parent', '___TEST___backup_directories', '___TEST___escalation_contacts', + '___TEST___maintenance_window', ]; foreach ($keyNames as $keyName) { $rows = $dba->fetchAll( From c6fd077ca69d0a1738fb4ce78cd620e1e1b3cfee Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 18:17:49 +0200 Subject: [PATCH 079/221] DeleteCustomVariableForm: Resolve the actual root property for deeply nested fields removeObjectCustomVars() and removeFromOverrideServiceVars() both assumed the root property was at most one hop above the deleted field's parent. Dictionaries can nest arbitrarily deep, so anything nested three or more levels down was resolved against the wrong root, causing the update to silently touch nothing (or the wrong path). resolveRootProperty() now walks parent_uuid all the way up instead of guessing the depth. --- .../forms/DeleteCustomVariableForm.php | 52 ++-- .../Form/DeleteCustomVariableFormTest.php | 282 +++++++++++++++--- 2 files changed, 277 insertions(+), 57 deletions(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index 4255d359d..95fce69d3 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -200,6 +200,31 @@ private function fetchProperty(UuidInterface $uuid): array return DbUtil::normalizeRow($db->fetchRow($query, [], Zend_Db::FETCH_ASSOC) ?: []); } + /** + * Find the root property by following parent_uuid up from $parent, collecting + * key_names along the way. Dictionaries can nest arbitrarily deep, so the root isn't + * always $parent's direct parent. + * + * @param array $property The property being deleted + * @param array $parent The immediate parent of the property being deleted + * + * @return array{0: array, 1: string[]} [$rootProperty, $pathWithinRootValue] + * $pathWithinRootValue lists the key_names from directly under the root down to + * (and including) $property['key_name']. + */ + private 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 give data array * @@ -311,17 +336,10 @@ private function removeFromOverrideServiceVars(array $property, array $parent): $rootKeyName = $property['key_name']; $rootType = $property['value_type']; $pathWithinRootValue = null; - } elseif ($parent['parent_uuid'] === null) { - // Child field of a root property deleted - $rootKeyName = $parent['key_name']; - $rootType = $parent['value_type']; - $pathWithinRootValue = [$property['key_name']]; } else { - // Nested child field deleted (grandparent is the root property) - $rootProp = $this->fetchProperty(Uuid::fromBytes($parent['parent_uuid'])); + [$rootProp, $pathWithinRootValue] = $this->resolveRootProperty($property, $parent); $rootKeyName = $rootProp['key_name']; $rootType = $rootProp['value_type']; - $pathWithinRootValue = [$parent['key_name'], $property['key_name']]; } // Fetch all hosts that have the _override_servicevars custom variable @@ -411,23 +429,11 @@ private function removeObjectCustomVars(array $property, ?array $parent = null): $db = $this->db->getDbAdapter(); $parentUuid = Uuid::fromBytes($parent['uuid']); - $rootUuid = null; - - if ($parent['parent_uuid'] !== null) { - // Parent is itself a field — grandparent is the root property - $rootUuid = Uuid::fromBytes($parent['parent_uuid']); - $rootProp = $this->fetchProperty($rootUuid); - $rootType = $rootProp['value_type']; - } else { - $rootUuid = $parentUuid; - $rootType = $parent['value_type']; - } // Path within the stored JSON to the key being deleted — constant for all rows - $path = [$property['key_name']]; - if ($parent['parent_uuid'] !== null) { - array_unshift($path, $parent['key_name']); - } + [$rootProp, $path] = $this->resolveRootProperty($property, $parent); + $rootUuid = Uuid::fromBytes($rootProp['uuid']); + $rootType = $rootProp['value_type']; // Re-index the fixed-array items in director_property once, before processing stored vars $isParentFixedArray = $parent['value_type'] === 'fixed-array'; diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index 655f4dd30..e386f8b35 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -498,6 +498,221 @@ public function testOnSuccessRollsBackTransactionWhenAnExceptionIsThrown(): void ); } + public function testDeletingThreeLevelsDeepFieldUpdatesHostVarWithoutCrashing(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // A host's notification policy: a root dictionary containing "business_hours", + // which itself contains an "escalation" dictionary with a contact_email field -- + // three levels of nesting below the custom variable's own icinga_host_var row. + $host = IcingaHost::create([ + 'object_name' => '___TEST___oncall01', + 'object_type' => 'object', + 'address' => '192.0.2.20', + ], $db); + $host->store(); + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___notification_policy', + 'value_type' => 'fixed-dictionary', + 'label' => 'Notification Policy', + ], $db)->store(); + + $businessHoursUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $businessHoursUuid->getBytes(), + 'key_name' => 'business_hours', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + 'label' => 'Business Hours', + ], $db)->store(); + + $escalationUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $escalationUuid->getBytes(), + 'key_name' => 'escalation', + 'parent_uuid' => $businessHoursUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + 'label' => 'Escalation', + ], $db)->store(); + + $contactEmailUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $contactEmailUuid->getBytes(), + 'key_name' => 'contact_email', + 'parent_uuid' => $escalationUuid->getBytes(), + 'value_type' => 'string', + 'label' => 'Contact Email', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___notification_policy', + 'varvalue' => json_encode([ + 'business_hours' => [ + 'escalation' => [ + 'contact_email' => 'ops@example.com', + 'phone' => '+1-555-0100', + ], + ], + ]), + 'format' => 'json', + 'property_uuid' => DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba), + ]); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $contactEmailUuid->getBytes(), + 'key_name' => 'contact_email', + 'value_type' => 'string', + 'label' => 'Contact Email', + 'description' => null, + 'parent_uuid' => $escalationUuid->getBytes(), + ], + [ + 'uuid' => $escalationUuid->getBytes(), + 'key_name' => 'escalation', + 'value_type' => 'fixed-dictionary', + 'parent_uuid' => $businessHoursUuid->getBytes(), + ] + ); + + self::callMethod($form, 'onSuccess', []); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___notification_policy') + ); + + $this->assertEquals( + ['business_hours' => ['escalation' => ['phone' => '+1-555-0100']]], + json_decode($updatedValue, true), + 'contact_email must be removed from the correct nested path even though the' + . ' root property is three levels above the deleted field' + ); + } + + public function testDeletingThreeLevelsDeepFieldUpdatesOverrideServiceVarsWithoutCrashing(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // A host overrides a service's alert routing: root dictionary "alert_routing" + // containing "team", which contains "priority", which contains a pager_email field -- + // three levels of nesting below the custom variable's own key in _override_servicevars. + $host = IcingaHost::create([ + 'object_name' => '___TEST___router01', + 'object_type' => 'object', + 'address' => '192.0.2.30', + ], $db); + $host->store(); + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___alert_routing', + 'value_type' => 'fixed-dictionary', + 'label' => 'Alert Routing', + ], $db)->store(); + + $teamUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $teamUuid->getBytes(), + 'key_name' => 'team', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + 'label' => 'Team', + ], $db)->store(); + + $priorityUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $priorityUuid->getBytes(), + 'key_name' => 'priority', + 'parent_uuid' => $teamUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + 'label' => 'Priority', + ], $db)->store(); + + $pagerEmailUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $pagerEmailUuid->getBytes(), + 'key_name' => 'pager_email', + 'parent_uuid' => $priorityUuid->getBytes(), + 'value_type' => 'string', + 'label' => 'Pager Email', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '_override_servicevars', + 'varvalue' => json_encode([ + 'web_check' => [ + '___TEST___alert_routing' => [ + 'team' => [ + 'priority' => [ + 'pager_email' => 'oncall@example.com', + 'sms' => '+1-555-0200', + ], + ], + ], + ], + ]), + 'format' => 'json', + ]); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $pagerEmailUuid->getBytes(), + 'key_name' => 'pager_email', + 'value_type' => 'string', + 'label' => 'Pager Email', + 'description' => null, + 'parent_uuid' => $priorityUuid->getBytes(), + ], + [ + 'uuid' => $priorityUuid->getBytes(), + 'key_name' => 'priority', + 'value_type' => 'fixed-dictionary', + 'parent_uuid' => $teamUuid->getBytes(), + ] + ); + + 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___alert_routing' => [ + 'team' => ['priority' => ['sms' => '+1-555-0200']], + ], + ], + ], + json_decode($updatedValue, true), + 'pager_email must be removed from the correct nested path even though the' + . ' root property is three levels above the deleted field' + ); + } + public function tearDown(): void { if ($this->hasDb()) { @@ -513,6 +728,8 @@ public function tearDown(): void $hostNames = [ '___TEST___backup01', '___TEST___maintenance01', + '___TEST___oncall01', + '___TEST___router01', ]; foreach ($hostNames as $hostName) { $dba->delete('icinga_host', ['object_name = ?' => $hostName]); @@ -525,6 +742,8 @@ public function tearDown(): void '___TEST___backup_directories', '___TEST___escalation_contacts', '___TEST___maintenance_window', + '___TEST___notification_policy', + '___TEST___alert_routing', ]; foreach ($keyNames as $keyName) { $rows = $dba->fetchAll( @@ -535,40 +754,7 @@ public function tearDown(): void 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. - $rowUuid = DbUtil::binaryResult($row->uuid); - $childUuids = array_map( - [DbUtil::class, 'binaryResult'], - $dba->fetchCol( - $dba->select()->from('director_property', ['uuid'])->where( - 'parent_uuid = ?', - DbUtil::quoteBinaryCompat($rowUuid, $dba) - ) - ) - ); - foreach ($childUuids as $childUuid) { - $dba->delete( - 'director_property_datalist', - $dba->quoteInto( - 'property_uuid = ?', - DbUtil::quoteBinaryCompat($childUuid, $dba) - ) - ); - } - $dba->delete( - 'director_property_datalist', - $dba->quoteInto( - 'property_uuid = ?', - DbUtil::quoteBinaryCompat($rowUuid, $dba) - ) - ); - $dba->delete( - 'director_property', - $dba->quoteInto( - 'parent_uuid = ?', - DbUtil::quoteBinaryCompat($rowUuid, $dba) - ) - ); - $dba->delete('director_property', $dba->quoteInto('key_name = ?', $keyName)); + $this->deletePropertyTree($dba, DbUtil::binaryResult($row->uuid)); } } @@ -579,4 +765,32 @@ public function tearDown(): void 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)) + ); + } } From 54facc0ab9a5c1a4e8d1132a6ab132e008468052 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 18:20:19 +0200 Subject: [PATCH 080/221] DeleteCustomVariableForm: Fix dynamic-dictionary entries not losing a deleted field removeObjectCustomVars() looped over a dynamic dictionary's entries by value and mutated the loop copy, so the removal was only written back to the stored value when an entry became fully empty -- any entry that survived with other fields intact kept the deleted field. Extracts the correct by-reference version (already used by removeFromOverrideServiceVars) into a shared removeDictionaryItemFromEveryEntry() used by both. --- .../forms/DeleteCustomVariableForm.php | 39 ++- .../Form/DeleteCustomVariableFormTest.php | 257 ++++++++++++++++++ 2 files changed, 283 insertions(+), 13 deletions(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index 95fce69d3..556401747 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -253,6 +253,27 @@ private function removeDictionaryItem(array &$item, array $path): void } } + /** + * Strip the given path out of every entry in a dynamic dictionary, in place. Entries + * that aren't arrays are left alone, and it's up to the caller to decide what to do + * with an entry that ends up empty afterwards. + * + * @param array $dynamicDictionaryValue The dynamic dictionary's entries, modified in place + * @param string[] $path The path (relative to each entry) to strip + * + * @return void + */ + private function removeDictionaryItemFromEveryEntry(array &$dynamicDictionaryValue, array $path): void + { + foreach ($dynamicDictionaryValue as $entryKey => $entryValue) { + if (! is_array($entryValue)) { + continue; + } + + $this->removeDictionaryItem($dynamicDictionaryValue[$entryKey], $path); + } + } + protected function onSuccess(): void { $uuid = $this->property['uuid']; @@ -367,15 +388,12 @@ private function removeFromOverrideServiceVars(array $property, array $parent): // Root property deleted: remove its key from the service's override vars unset($serviceVars[$rootKeyName]); } elseif ($rootType === 'dynamic-dictionary') { - // Dynamic dictionary: remove the path from every dynamic entry + // Dynamic dictionary: remove the path from every dynamic entry, dropping + // any entry that becomes empty as a result if (is_array($serviceVars[$rootKeyName])) { + $this->removeDictionaryItemFromEveryEntry($serviceVars[$rootKeyName], $pathWithinRootValue); foreach ($serviceVars[$rootKeyName] as $entryKey => $entryValue) { - if (! is_array($entryValue)) { - continue; - } - - $this->removeDictionaryItem($serviceVars[$rootKeyName][$entryKey], $pathWithinRootValue); - if (empty($serviceVars[$rootKeyName][$entryKey])) { + if ($entryValue === []) { unset($serviceVars[$rootKeyName][$entryKey]); } } @@ -461,13 +479,8 @@ private function removeObjectCustomVars(array $property, ?array $parent = null): if ($rootType !== 'dynamic-dictionary') { $this->removeDictionaryItem($varValue, $path); } else { + $this->removeDictionaryItemFromEveryEntry($varValue, $path); foreach ($varValue as $entryKey => $entryValue) { - if (! is_array($entryValue)) { - continue; - } - - $this->removeDictionaryItem($entryValue, $path); - if ($entryValue === []) { $varValue[$entryKey] = (object) []; } diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index e386f8b35..cae4723aa 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -713,6 +713,257 @@ public function testDeletingThreeLevelsDeepFieldUpdatesOverrideServiceVarsWithou ); } + 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 tearDown(): void { if ($this->hasDb()) { @@ -730,6 +981,9 @@ public function tearDown(): void '___TEST___maintenance01', '___TEST___oncall01', '___TEST___router01', + '___TEST___multidc01', + '___TEST___multidc02', + '___TEST___multidc03', ]; foreach ($hostNames as $hostName) { $dba->delete('icinga_host', ['object_name = ?' => $hostName]); @@ -744,6 +998,9 @@ public function tearDown(): void '___TEST___maintenance_window', '___TEST___notification_policy', '___TEST___alert_routing', + '___TEST___datacenter_settings', + '___TEST___datacenter_settings_2', + '___TEST___datacenter_settings_3', ]; foreach ($keyNames as $keyName) { $rows = $dba->fetchAll( From cf372d0f2597b8c08cccc4ed6150ed9e0930aa09 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 8 Jul 2026 23:20:23 +0200 Subject: [PATCH 081/221] DeleteCustomVariableForm: Renumber fixed-array items in place instead of delete+reinsert updateFixedArrayItems() deleted every sibling item and reinserted them with new key_names, which needlessly cascaded away (and had to manually restore) each item's director_property_datalist link, and would silently drop any other table's reference to an item's uuid, such as an icinga_host_property "required" binding on an individual item. Deletes only the removed item and updates key_name in place on survivors. --- .../forms/DeleteCustomVariableForm.php | 81 +++++++---------- .../Form/DeleteCustomVariableFormTest.php | 90 +++++++++++++++++++ 2 files changed, 124 insertions(+), 47 deletions(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index 556401747..d19e0528f 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -522,60 +522,47 @@ private function updateFixedArrayItems(UuidInterface $uuid, string $propertyInde { $db = $this->db->getDbAdapter(); $quotedUuid = DbUtil::quoteBinaryCompat($uuid->getBytes(), $db); - $query = $db - ->select() - ->from(['dp' => 'director_property'], []) - ->columns([ - 'key_name', - 'uuid', - 'parent_uuid', - 'value_type', - 'label', - 'description', - 'category_id' - ]) - ->joinLeft( - ['dpdl' => 'director_property_datalist'], - 'dpdl.property_uuid = dp.uuid', - ['list_uuid'] - ) - ->where('dp.parent_uuid = ?', $quotedUuid) - ->where('dp.key_name != ?', $propertyIndex); - $propItems = array_map([DbUtil::class, 'normalizeRow'], $db->fetchAll($query, [], Zend_Db::FETCH_ASSOC)); - // key_name is a varchar column; a fixed array's item indexes must be sorted numerically - // here, not lexicographically (otherwise '10' would sort before '2'). - usort($propItems, fn($a, $b) => (int) $a['key_name'] <=> (int) $b['key_name']); - - // Deleting director_property here cascades away any director_property_datalist link - // for these items (ON DELETE CASCADE on property_uuid) — list_uuid was captured above - // so each item's link can be reinserted below, after the uuid exists again. + // Delete the item being removed first, freeing up its key_name slot before any + // surviving sibling is renumbered into it — key_name is unique per parent_uuid. $db->delete( 'director_property', - ['parent_uuid = ?' => $quotedUuid] + [ + 'parent_uuid = ?' => $quotedUuid, + 'key_name = ?' => $propertyIndex, + ] ); - $count = 0; - foreach ($propItems as $propItem) { - $itemUuid = Uuid::fromBytes($propItem['uuid'])->getBytes(); - $this->db->insert('director_property', [ - 'uuid' => DbUtil::quoteBinaryCompat($itemUuid, $db), - 'parent_uuid' => $quotedUuid, - 'key_name' => $count, - 'label' => $propItem['label'], - 'value_type' => $propItem['value_type'], - 'description' => $propItem['description'], - 'category_id' => $propItem['category_id'] - ]); - - if ($propItem['list_uuid'] !== null) { - $this->db->insert('director_property_datalist', [ - 'property_uuid' => DbUtil::quoteBinaryCompat($itemUuid, $db), - 'list_uuid' => DbUtil::quoteBinaryCompat($propItem['list_uuid'], $db) - ]); + $propItems = array_map( + [DbUtil::class, 'normalizeRow'], + $db->fetchAll( + $db->select() + ->from('director_property', ['uuid', 'key_name']) + ->where('parent_uuid = ?', $quotedUuid), + [], + Zend_Db::FETCH_ASSOC + ) + ); + // key_name is a varchar column; a fixed array's item indexes must be sorted numerically + // here, not lexicographically (otherwise '10' would sort before '2'). + usort($propItems, fn($a, $b) => (int) $a['key_name'] <=> (int) $b['key_name']); + + // Renumber survivors in place — everything else about each item's row (category, + // label, value_type, description, director_property_datalist link, and any other + // table referencing its uuid, such as icinga_host_property) is left untouched. + foreach ($propItems as $index => $propItem) { + if ($propItem['key_name'] === (string) $index) { + continue; } - $count++; + $db->update( + 'director_property', + ['key_name' => $index], + $db->quoteInto( + 'uuid = ?', + DbUtil::quoteBinaryCompat($propItem['uuid'], $db) + ) + ); } } } diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index cae4723aa..ada68df30 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -964,6 +964,94 @@ public function testDeletingDynamicDictionaryFieldUpdatesEveryEntryInOverrideSer ); } + 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()) { @@ -984,6 +1072,7 @@ public function tearDown(): void '___TEST___multidc01', '___TEST___multidc02', '___TEST___multidc03', + '___TEST___oncall_template', ]; foreach ($hostNames as $hostName) { $dba->delete('icinga_host', ['object_name = ?' => $hostName]); @@ -1001,6 +1090,7 @@ public function tearDown(): void '___TEST___datacenter_settings', '___TEST___datacenter_settings_2', '___TEST___datacenter_settings_3', + '___TEST___contact_numbers', ]; foreach ($keyNames as $keyName) { $rows = $dba->fetchAll( From ddfe8ec38a28f1bb4e968adbab7d97c2790ed92b Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 10:16:28 +0200 Subject: [PATCH 082/221] CustomVariablesForm: Fix saving of fixed-array type custom variables --- application/forms/CustomVariablesForm.php | 21 +++++++++---- .../Director/Form/CustomVariablesFormTest.php | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index aaf3528fb..360702fee 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -294,6 +294,21 @@ public function prepareNewPropertyRow(array $propertyData, int $index): BaseHtml */ 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 (is_bool($checkedItem) || ! empty($checkedItem)) { + return $array; + } + } + + return []; + } + return array_filter( array_map(function ($item) { if (! is_array($item)) { @@ -386,11 +401,7 @@ protected function onSuccess(): void return empty($filtered) ? (object) [] : $filtered; }, $value); } else { - $filteredValue = self::filterEmpty($value); - // Store the fixed array as empty only if the filtered array is empty - if ($property['value_type'] !== 'fixed-array' || empty($filteredValue)) { - $value = $filteredValue; - } + $value = self::filterEmpty($value); } } diff --git a/test/php/library/Director/Form/CustomVariablesFormTest.php b/test/php/library/Director/Form/CustomVariablesFormTest.php index 917b04c8c..2f805d64d 100644 --- a/test/php/library/Director/Form/CustomVariablesFormTest.php +++ b/test/php/library/Director/Form/CustomVariablesFormTest.php @@ -191,4 +191,34 @@ public function testEmptyArrayReturnsEmpty(): void { $this->assertSame([], CustomVariablesForm::filterEmpty([])); } + + public function testFixedArrayKeepsEmptyMiddleSlotInPlace(): void + { + // A fixed array's items are positional -- dropping the empty middle one would shift + // the third item into its place. + $result = CustomVariablesForm::filterEmpty(['a', '', 'c']); + $this->assertSame(['a', '', 'c'], $result); + } + + 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' => ['a', '', 'c']]; + $result = CustomVariablesForm::filterEmpty($entry); + $this->assertSame(['label' => 'dc1', 'slots' => ['a', '', 'c']], $result); + } + + public function testFixedArrayNestedInsideDictionaryEntryIsDroppedWhenFullyEmpty(): void + { + $entry = ['label' => 'dc1', 'slots' => ['', '', '']]; + $result = CustomVariablesForm::filterEmpty($entry); + $this->assertSame(['label' => 'dc1'], $result); + } } From 84447d3ad1cdc9a27a508c67f78d6969bd8e3af3 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 10:18:26 +0200 Subject: [PATCH 083/221] CustomVariablesForm: Remove unnecessary use of DbUtil::quoteBinaryCompat() --- application/forms/CustomVariablesForm.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index 360702fee..3cfbf312f 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -441,7 +441,7 @@ protected function onSuccess(): void $db ->select() ->from('icinga_' . $type . '_var') - ->where('property_uuid IN (?)', DbUtil::quoteBinaryCompat($itemsToRemoveUuids, $db)) + ->where('property_uuid IN (?)', $itemsToRemoveUuids) ); foreach ($propertyAsObjectVar as $propertyAsObjectVarRow) { From 6c7d111eb60974efadb219a9284d6c9e25428b0a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 10:41:41 +0200 Subject: [PATCH 084/221] DictionaryItem: Sort fixed-array items by there key_name (numerically) --- application/forms/DictionaryElements/DictionaryItem.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index 1627b29a1..0f90e5a9f 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -399,6 +399,13 @@ private static function fetchChildrenItems(UuidInterface $parentUuid, string $pa ->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 field name, 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']); From 24b21503a5f1a898d6928b7b476d947b164cf7d4 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 12:15:47 +0200 Subject: [PATCH 085/221] MigrateCommand: Skip unsupported datafield types even without verbose mode The skip for datafields with an unsupported value type only ran when verbose output was enabled, since the continue was nested inside the isVerbose check along with the message. Without --verbose the loop fell through and tried to insert the unsupported value_type into the director_property table, which failed because the column is an enum. --- application/clicommands/MigrateCommand.php | 10 +++--- .../Director/Objects/MigrateCommandTest.php | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index 9cdf43af9..c8ba4b495 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -207,10 +207,12 @@ private function migrateDatafields(array $customProperties, bool $dryRun): void } foreach ($customProperties as $varName => $customProperty) { - if ($this->isVerbose && str_starts_with($customProperty['value_type'], 'unsupported-')) { - echo "[-] Skipping migration of datafield '{$varName}' as it has an unsupported datatype '" - . substr($customProperty['value_type'], strlen('unsupported-')) - . "'\n"; + 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; } diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index d4c0a1a2f..39e477198 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -38,6 +38,8 @@ class MigrateCommandTest extends BaseTestCase private const VAR_DUP = self::PREFIX . 'dup_field'; + 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'; @@ -60,6 +62,7 @@ class MigrateCommandTest extends BaseTestCase self::VAR_CATEGORIZED, self::VAR_HIDDEN, self::VAR_DUP, + self::VAR_TIME_FIELD, ]; public function testDryRunPrintsWhatWouldMigrateWithoutWriting(): void @@ -355,6 +358,27 @@ public function testUnsupportedTypeIsSkipped(): void $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 testDuplicateNamesAreSkipped(): void { if ($this->skipForMissingDb()) { @@ -517,6 +541,13 @@ private function createAllFixtures(Db $db): void 'caption' => 'Dup B', '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 deleteTestDatafields(Db $db): void From a11d17b91d110cbd195770b7d3b3acc35d003c27 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 12:18:47 +0200 Subject: [PATCH 086/221] MigrateCommand: Roll back the datafield migration loop on failure migrateDatafields used a bare beginTransaction/commit around its insert loop instead of the fail safe transaction helper, so if any insert in the loop threw partway through, the transaction was left open instead of being rolled back. Wrapping the loop body in a closure and running it through Db::runFailSafeTransaction lets it roll back cleanly on any error, while dry runs still skip the transaction entirely since they never write. --- application/clicommands/MigrateCommand.php | 115 +++++++++--------- .../Director/Objects/MigrateCommandTest.php | 63 ++++++++++ 2 files changed, 121 insertions(+), 57 deletions(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index c8ba4b495..4324a02c4 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -201,73 +201,74 @@ private function prepareCustomProperties(): array private function migrateDatafields(array $customProperties, bool $dryRun): void { $db = $this->db(); - $dbAdapter = $db->getDbAdapter(); - if (! $dryRun) { - $dbAdapter->beginTransaction(); - } - 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"; + $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; } - 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']); - } - - $this->migratedDataFields[$customProperty['datafield_id']] = $varName; - 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) - ]); + $itemType = null; + if (isset($customProperty['item_type'])) { + $itemType = $customProperty['item_type']; + unset($customProperty['item_type']); } - if ($datalistUuidBytes !== null) { - $db->insert('director_property_datalist', [ - 'property_uuid' => DbUtil::quoteBinaryCompat($uuidBytes, $dbAdapter), - 'list_uuid' => DbUtil::quoteBinaryCompat($datalistUuidBytes, $dbAdapter) - ]); + $datalistUuidBytes = null; + if (isset($customProperty['datalist_uuid'])) { + $datalistUuidBytes = $customProperty['datalist_uuid']; + unset($customProperty['datalist_uuid']); } - $this->migrateDatafieldObjectTemplateBinding($datafieldId, $propertyUuid); - } + $this->migratedDataFields[$customProperty['datafield_id']] = $varName; + 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); + } - if ($dryRun) { - echo "[*] Would migrate datafield '$varName'\n"; - } elseif ($this->isVerbose) { - echo "[+] Datafield '$varName' successfully migrated\n"; + if ($dryRun) { + echo "[*] Would migrate datafield '$varName'\n"; + } elseif ($this->isVerbose) { + echo "[+] Datafield '$varName' successfully migrated\n"; + } } - } + }; - if (! $dryRun) { - $dbAdapter->commit(); + if ($dryRun) { + $migrate(); + } else { + $db->runFailSafeTransaction($migrate); } } diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index 39e477198..36d56ef41 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -63,6 +63,8 @@ class MigrateCommandTest extends BaseTestCase self::VAR_HIDDEN, self::VAR_DUP, self::VAR_TIME_FIELD, + self::PREFIX . 'rollback_first', + self::PREFIX . 'rollback_second', ]; public function testDryRunPrintsWhatWouldMigrateWithoutWriting(): void @@ -379,6 +381,62 @@ public function testUnsupportedTimeTypeIsSkippedEvenWithoutVerbose(): void $this->assertEquals(0, (int) $count); } + public function testMigrateDatafieldsRollsBackOnMidLoopFailure(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $sharedUuid = Uuid::uuid4()->getBytes(); + $customProperties = [ + self::PREFIX . 'rollback_first' => [ + 'datafield_id' => 90001, + 'uuid' => $sharedUuid, + 'key_name' => self::PREFIX . 'rollback_first', + 'label' => null, + 'description' => null, + 'category_id' => null, + 'value_type' => 'string', + ], + self::PREFIX . 'rollback_second' => [ + 'datafield_id' => 90002, + 'uuid' => $sharedUuid, + 'key_name' => self::PREFIX . 'rollback_second', + '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 . 'rollback_first') + ); + $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()) { @@ -428,6 +486,11 @@ protected function tearDown(): void { if ($this->hasDb()) { $db = $this->getDb(); + $dba = $db->getDbAdapter(); + if ($dba->getConnection()->inTransaction()) { + $dba->getConnection()->rollBack(); + } + $this->deleteTestProperties($db); $this->deleteTestDatafields($db); $this->deleteTestCategory($db); From 2ff9de5e782d919b2f3e9e5675a73225da95564b Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 12:25:21 +0200 Subject: [PATCH 087/221] MigrateCommand: Only record a datafield as migrated when it was actually written migratedDataFields got the same entry added twice, once unconditionally right before the dry run check and once again inside the write branch. Because of the unconditional write, a dry run with --delete --verbose would print datafields as having been migrated and deleted even though nothing was ever touched. Dropping the duplicate outside the write branch means the list only ever reflects what was actually inserted. --- application/clicommands/MigrateCommand.php | 1 - .../Director/Objects/MigrateCommandTest.php | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index 4324a02c4..97389fcf1 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -227,7 +227,6 @@ private function migrateDatafields(array $customProperties, bool $dryRun): void unset($customProperty['datalist_uuid']); } - $this->migratedDataFields[$customProperty['datafield_id']] = $varName; if (! $dryRun) { $datafieldId = $customProperty['datafield_id']; unset($customProperty['datafield_id']); diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index 36d56ef41..d30d51aff 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -97,6 +97,21 @@ public function testDryRunPrintsWhatWouldMigrateWithoutWriting(): void } } + public function testDryRunWithDeleteDoesNotReportDatafieldsAsDeleted(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $this->createAllFixtures($db); + + $cmd = new TestableMigrateCommand($db, ['--dry-run', '--delete', '--verbose']); + $output = $cmd->runDatafields(); + + $this->assertStringContainsString("have been migrated and deleted:\nSummary:", $output); + } + public function testLiveMigrationCreatesDirectorPropertyRows(): void { if ($this->skipForMissingDb()) { From 8159e29afec5b1932f946e852134abbe8379a366 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 13:11:19 +0200 Subject: [PATCH 088/221] MigrateCommand: Avoid unnecessarily loading all objects --- application/clicommands/MigrateCommand.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index 97389fcf1..cc4499898 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -7,7 +7,6 @@ use Icinga\Data\Filter\FilterAnd; use Icinga\Data\Filter\FilterMatch; use Icinga\Module\Director\Cli\Command; -use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; use Icinga\Module\Director\Db\DbSelectParenthesis; use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\Objects\DirectorDatafield; @@ -500,21 +499,20 @@ private function migrateDatafieldObjectTemplateBinding(int $datafieldId, UuidInt { $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}"], ['*']) + $query = $dbAdapter->select()->from(['io' => "icinga_{$type}"], ['uuid']) ->join(['iof' => "icinga_{$type}_field"], "io.id = iof.{$type}_id", []) ->where('iof.datafield_id = ?', $datafieldId); - $objectInstance = DbObjectTypeRegistry::classByType($type); - $objects = $objectInstance::loadAll($db, $query); - foreach ($objects as $object) { + foreach ($dbAdapter->fetchCol($query) as $objectUuid) { $db->insert( "icinga_{$type}_property", [ - 'property_uuid' => DbUtil::quoteBinaryCompat($propertyUuid->getBytes(), $dbAdapter), + 'property_uuid' => $propertyUuidExpr, "{$type}_uuid" => DbUtil::quoteBinaryCompat( - DbUtil::binaryResult($object->get('uuid')), + DbUtil::binaryResult($objectUuid), $dbAdapter ) ] From 969ad06ad476e3807fd44f8e196cad2369a72fd8 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 13:24:24 +0200 Subject: [PATCH 089/221] Fix possible schema drift on director_datalist's UNIQUE KEY upgrade_186.sql already adds a unique index on director_datalist.uuid. But this is not added in the mysql.sql. Hence, there is a need to check if the unique index was already created for the uuid column in director_datalist before creating it. --- schema/mysql-migrations/upgrade_193.sql | 18 ++++++++++++++++-- schema/mysql.sql | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/schema/mysql-migrations/upgrade_193.sql b/schema/mysql-migrations/upgrade_193.sql index 47550a78b..4f1ee875d 100644 --- a/schema/mysql-migrations/upgrade_193.sql +++ b/schema/mysql-migrations/upgrade_193.sql @@ -128,8 +128,22 @@ CREATE TABLE icinga_user_property ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -ALTER TABLE director_datalist - ADD UNIQUE KEY (uuid); +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, diff --git a/schema/mysql.sql b/schema/mysql.sql index 7a9d0ff57..7c101975d 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -278,7 +278,7 @@ CREATE TABLE director_property ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; ALTER TABLE director_datalist - ADD UNIQUE KEY (uuid); + ADD UNIQUE KEY uuid (uuid); CREATE TABLE director_property_datalist ( list_uuid varbinary(16) NOT NULL, From 5eba7b0d3a6ab0397e04beffdd3cf370be955392 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 13:53:13 +0200 Subject: [PATCH 090/221] Index property_uuid for icinga_{object}_property This index is about making the property-uuid-first lookups not scan the whole table. --- schema/pgsql-migrations/upgrade_193.sql | 12 ++++++++++++ schema/pgsql.sql | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/schema/pgsql-migrations/upgrade_193.sql b/schema/pgsql-migrations/upgrade_193.sql index d225d325b..2a6130c7c 100644 --- a/schema/pgsql-migrations/upgrade_193.sql +++ b/schema/pgsql-migrations/upgrade_193.sql @@ -53,6 +53,8 @@ CREATE TABLE icinga_host_property ( 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, @@ -70,6 +72,8 @@ CREATE TABLE icinga_service_property ( 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, @@ -87,6 +91,8 @@ CREATE TABLE icinga_command_property ( 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, @@ -104,6 +110,8 @@ CREATE TABLE icinga_notification_property ( 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, @@ -121,6 +129,8 @@ CREATE TABLE icinga_service_set_property ( 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, @@ -138,6 +148,8 @@ CREATE TABLE icinga_user_property ( 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); diff --git a/schema/pgsql.sql b/schema/pgsql.sql index 206710031..bc4074f23 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -642,6 +642,8 @@ CREATE TABLE icinga_command_property ( 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, checksum bytea DEFAULT NULL UNIQUE CHECK(LENGTH(checksum) = 20), @@ -892,6 +894,8 @@ CREATE TABLE icinga_host_property ( 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), @@ -1086,6 +1090,8 @@ CREATE TABLE icinga_service_property ( 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, @@ -1221,6 +1227,8 @@ CREATE TABLE icinga_service_set_property ( 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, @@ -1566,6 +1574,8 @@ CREATE TABLE icinga_user_property ( ON UPDATE CASCADE ); +CREATE INDEX icinga_user_property_property_uuid ON icinga_user_property (property_uuid); + CREATE TABLE icinga_usergroup ( id serial, @@ -2040,6 +2050,8 @@ CREATE TABLE icinga_notification_property ( 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, From a3d0d6f65a8606ec3193621f508c41a2ef911a82 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 13:55:34 +0200 Subject: [PATCH 091/221] mysql.sql: Fix column order mismatch for director_property with upgrade_193.sql --- schema/mysql.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/mysql.sql b/schema/mysql.sql index 7c101975d..1bf1f12d6 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -254,7 +254,6 @@ CREATE TABLE director_property ( parent_uuid varbinary(16) NULL DEFAULT NULL, key_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, label varchar(255) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, - description text DEFAULT NULL, value_type enum( 'string', 'number', @@ -267,6 +266,7 @@ CREATE TABLE director_property ( '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), From 3880fad05d9883e508baa17f1daa47b994708e4f Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 14:01:24 +0200 Subject: [PATCH 092/221] DirectorProperty: Introduce stripVirtualParentUuidColumn() method to remove duplicated code --- library/Director/Objects/DirectorProperty.php | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index 5832edb74..99c967ff4 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -48,26 +48,34 @@ class DirectorProperty extends DbObject protected function setDbProperties($properties) { - $connection = $this->getConnection(); if (! is_array($properties)) { $properties = (array) $properties; } - if ($connection && $connection->isMysql() && isset($properties['parent_uuid_v'])) { - unset($properties['parent_uuid_v']); // hack to ignore virtual column, need a better solution - } - - return parent::setDbProperties($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($props['parent_uuid_v'])) { - unset($props['parent_uuid_v']); + if ($connection && $connection->isMysql() && isset($properties['parent_uuid_v'])) { + unset($properties['parent_uuid_v']); } - return parent::setProperties($props); + return $properties; } /** From 45230181f5784f413fe992b4ff8fb9b4c5f51b84 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 15:53:25 +0200 Subject: [PATCH 093/221] MigrateCommand: Take the fields is_required and var_filter into scope during migration --- application/clicommands/MigrateCommand.php | 24 ++-- .../Director/Objects/MigrateCommandTest.php | 119 ++++++++++++++++++ 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index cc4499898..f2355c09e 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -12,6 +12,7 @@ use Icinga\Module\Director\Objects\DirectorDatafield; use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Objects\DirectorProperty; +use PDO; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; @@ -252,7 +253,7 @@ private function migrateDatafields(array $customProperties, bool $dryRun): void ]); } - $this->migrateDatafieldObjectTemplateBinding($datafieldId, $propertyUuid); + $this->migrateDatafieldObjectTemplateBinding($datafieldId, $propertyUuid, $varName); } if ($dryRun) { @@ -495,26 +496,35 @@ private function getDatafieldsWithCategory(): array * * @return void */ - private function migrateDatafieldObjectTemplateBinding(int $datafieldId, UuidInterface $propertyUuid): 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", []) + ->join(['iof' => "icinga_{$type}_field"], "io.id = iof.{$type}_id", ['is_required', 'var_filter']) ->where('iof.datafield_id = ?', $datafieldId); - foreach ($dbAdapter->fetchCol($query) as $objectUuid) { + 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($objectUuid), + DbUtil::binaryResult($row['uuid']), $dbAdapter - ) + ), + 'required' => $row['is_required'], ] ); } diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index d30d51aff..3fbe97095 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -10,6 +10,8 @@ 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; @@ -44,6 +46,8 @@ class MigrateCommandTest extends BaseTestCase 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, @@ -497,6 +501,86 @@ public function testExistingCustomPropertyBlocksMigration(): void $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()) { @@ -506,6 +590,10 @@ protected function tearDown(): void $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); @@ -628,6 +716,37 @@ private function createAllFixtures(Db $db): void ], $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(); From 87582b290c7ab0764f080865150d6f3db8ed38bd Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 23:04:35 +0200 Subject: [PATCH 094/221] IcingaObjectHandler: Require a resolved object for the variables REST action Setting variables via POST/PUT without an identifying parameter passed null into CustomVariableValueApplier::apply(), crashing with a TypeError instead of a clean error response. --- library/Director/RestApi/IcingaObjectHandler.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index d5020e046..a49479709 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -181,6 +181,13 @@ protected function handleApiRequest() $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.' + ); + } + $overRiddenCustomVars = $data; } else { // Extract custom vars from the data From 365e8af0b0af4437396d0f479dd74eaf17ea49f5 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 23:10:50 +0200 Subject: [PATCH 095/221] DirectorProperty: Fix fatal error in import() when a property's parent has been orphaned parent_uuid has no FK back to director_property, so a deleted parent left a null dereference during basket import, plus a follow-on JSON encode crash from comparing a string parent_uuid against raw binary bytes. --- library/Director/Objects/DirectorProperty.php | 17 ++++++-- .../Director/Objects/DirectorPropertyTest.php | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index 99c967ff4..e9f04180a 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -354,9 +354,14 @@ public static function import(stdClass $plain, Db $db): static $candidate = DirectorProperty::fromDbRow($dbRow, $db); $export = $candidate->export(); if (isset($export->parent_uuid)) { - $export->parent = DirectorProperty::loadWithUniqueId(Uuid::fromString($export->parent_uuid), $db) - ->get('key_name'); - unset($export->parent_uuid); + $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); @@ -364,8 +369,12 @@ public static function import(stdClass $plain, Db $db): static 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 = $plainParentUuid; + $plain->parent_uuid = Uuid::fromBytes($plainParentUuid)->toString(); } else { $plain->parent = $parent->get('key_name'); unset($plain->parent_uuid); diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 7e383e504..7beb2cf4a 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -427,6 +427,49 @@ public function testExportRoundTrip(): void $this->assertEquals(['crit', 'warn'], $childKeys); } + public function testImportWithOrphanedParentDoesNotCrash(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $parent = $this->makeProperty('orphan_parent', 'fixed-dictionary', 'Orphan Parent', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + $childKeyName = self::PREFIX . 'orphan_child'; + $this->createdKeyNames[] = $childKeyName; + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $childKeyName, + 'parent_uuid' => $parentUuid, + 'value_type' => 'string', + ], $db)->store(); + + // Simulate a dangling parent reference: director_property.parent_uuid has no FK + // enforcing this link (see schema/mysql.sql), so this state is reachable in practice. + $dba->delete( + 'director_property', + $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($parentUuid, $dba)) + ); + + // This branch expects parent_uuid as raw bytes (matching how the equivalent + // lookup for $plain->parent_uuid is used at Uuid::fromBytes() a few lines down + // in import()), unlike the has-uuid branch's string-form convention. + $plain = (object) [ + 'key_name' => $childKeyName, + 'parent_uuid' => $parentUuid, + 'value_type' => 'string', + ]; + + $imported = DirectorProperty::import($plain, $db); + + $this->assertInstanceOf(DirectorProperty::class, $imported); + } + public function testImportIsIdempotent(): void { if ($this->skipForMissingDb()) { From a164440973bb58c88c8ef2edf2c96a86b09b405d Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 9 Jul 2026 23:56:25 +0200 Subject: [PATCH 096/221] CustomVariableForm: Roll back the transaction in onSuccess() on failure Without a rollback, a failed save left the transaction open. On PostgreSQL that aborts the connection, so the next query on the same request fails too instead of showing a normal error. --- application/forms/CustomVariableForm.php | 15 ++++-- .../Director/Form/CustomVariableFormTest.php | 48 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index 24c4747a3..78a193557 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -18,6 +18,7 @@ use PDO; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; +use Throwable; use Zend_Db; use Zend_Db_Select_Exception; @@ -445,10 +446,16 @@ protected function onSuccess(): void } $this->db->getDbAdapter()->beginTransaction(); - if ($this->uuid === null) { - $this->addNewProperty($values, $datalist, $itemType); - } else { - $this->updateExistingProperty($values, $datalist, $itemType); + 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(); diff --git a/test/php/library/Director/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php index 386385064..7974f4ca9 100644 --- a/test/php/library/Director/Form/CustomVariableFormTest.php +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -129,6 +129,54 @@ public function testUpdateStringPropertyKeyName(): void $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 tearDown(): void { if ($this->hasDb()) { From a5a4d3b47e28577c57af44a1706cf9382f94520a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 10 Jul 2026 00:03:35 +0200 Subject: [PATCH 097/221] CustomVariablesForm: Stop filterEmpty() from dropping 0, '0' and 0.0 values Nested dictionary and array sub-values of 0/'0'/0.0 were silently wiped on save. Reuses isValueUnset(), which already got this right for top-level scalars. --- application/forms/CustomVariablesForm.php | 6 ++--- .../Director/Form/CustomVariablesFormTest.php | 23 +++++++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index 3cfbf312f..275411538 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -301,7 +301,7 @@ public static function filterEmpty(array $array): array if (array_is_list($array)) { foreach ($array as $item) { $checkedItem = is_array($item) ? self::filterEmpty($item) : $item; - if (is_bool($checkedItem) || ! empty($checkedItem)) { + if (! self::isValueUnset($checkedItem)) { return $array; } } @@ -318,7 +318,7 @@ public static function filterEmpty(array $array): array return self::filterEmpty($item); }, $array), function ($item) { - return is_bool($item) || ! empty($item); + return ! self::isValueUnset($item); } ); } @@ -350,7 +350,7 @@ private function assertCanAttachNewVariable(): void * * @return bool */ - private static function isValueUnset(mixed $value): bool + public static function isValueUnset(mixed $value): bool { if (is_bool($value) || $value === 0 || $value === 0.0 || $value === '0') { return false; diff --git a/test/php/library/Director/Form/CustomVariablesFormTest.php b/test/php/library/Director/Form/CustomVariablesFormTest.php index 2f805d64d..97fff30c1 100644 --- a/test/php/library/Director/Form/CustomVariablesFormTest.php +++ b/test/php/library/Director/Form/CustomVariablesFormTest.php @@ -169,10 +169,29 @@ public function testFiltersNullValues(): void $this->assertSame(['check_command' => 'ping'], $result); } - public function testFiltersIntegerZero(): void + public function testKeepsIntegerZero(): void { $result = CustomVariablesForm::filterEmpty(['retry_count' => 0, 'max_check_attempts' => 3]); - $this->assertSame(['max_check_attempts' => 3], $result); + $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 From 5d381198342a91708c47e08a99af05579f22425a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 10 Jul 2026 00:08:44 +0200 Subject: [PATCH 098/221] NestedDictionaryItem: Preserve falsy 0/false/'0' values when re-displaying a form A nested dictionary field with a real 0 or false value was showing as empty on the Custom Variables tab, using the same empty() check that CustomVariablesForm already had to fix for top-level scalars. --- .../NestedDictionaryItem.php | 6 ++- .../NestedDictionaryItemTest.php | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 test/php/library/Director/Form/DictionaryElements/NestedDictionaryItemTest.php diff --git a/application/forms/DictionaryElements/NestedDictionaryItem.php b/application/forms/DictionaryElements/NestedDictionaryItem.php index b291d0561..243db0ec3 100644 --- a/application/forms/DictionaryElements/NestedDictionaryItem.php +++ b/application/forms/DictionaryElements/NestedDictionaryItem.php @@ -2,6 +2,7 @@ namespace Icinga\Module\Director\Forms\DictionaryElements; +use Icinga\Module\Director\Forms\CustomVariablesForm; use ipl\Html\Contract\FormElement; use ipl\Html\FormElement\FieldsetElement; use ipl\Html\FormElement\SubmitButtonElement; @@ -101,7 +102,10 @@ public static function prepare(array $nestedItems, array $property): array { $nestedValues = []; foreach ($nestedItems as $nestedItem) { - if (isset($property[$nestedItem['key_name']]) && ! empty($property[$nestedItem['key_name']])) { + if ( + isset($property[$nestedItem['key_name']]) + && ! CustomVariablesForm::isValueUnset($property[$nestedItem['key_name']]) + ) { $nestedItem['value'] = $property[$nestedItem['key_name']]; } 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']); + } +} From 767c0ac7e49b409f3a25a7e954e3e558305943f8 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 10 Jul 2026 00:12:52 +0200 Subject: [PATCH 099/221] MigrateCommand: Default a datalist datafield's missing 'behavior' setting to strict A legacy datalist datafield that never had this setting explicitly saved migrated as non-strict instead of strict, silently loosening validation. DataTypeDatalist already defaults the same setting to strict. --- application/clicommands/MigrateCommand.php | 2 +- .../Director/Objects/MigrateCommandTest.php | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index f2355c09e..76ac915c2 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -164,7 +164,7 @@ private function prepareCustomProperties(): array } elseif ($dataType === 'datalist') { $datalist = DirectorDatafield::load($row->id, $db); $settings = $datalist->getSettings(); - $behaviour = $settings['behavior']; + $behaviour = $settings['behavior'] ?? 'strict'; if ($behaviour === 'strict' || $behaviour === 'suggest_strict') { $customProperty['value_type'] = 'datalist-strict'; } else { diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index 3fbe97095..ffb176309 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -31,6 +31,8 @@ class MigrateCommandTest extends BaseTestCase 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'; @@ -62,6 +64,7 @@ class MigrateCommandTest extends BaseTestCase 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, @@ -200,6 +203,33 @@ public function testDatalistNonStrictMigratesCorrectly(): void $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()) { @@ -667,6 +697,17 @@ private function createAllFixtures(Db $db): void $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, From f796fce15719d52939d50f245447b69055b2a739 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 10 Jul 2026 09:32:20 +0200 Subject: [PATCH 100/221] CustomVariableDictionary: Sort keys before serializing getValue() Two exports of the same dictionary data could produce byte-different JSON just from key insertion order, breaking diff/golden-file comparisons. --- .../Director/CustomVariable/CustomVariableDictionary.php | 5 ++++- .../Director/CustomVariable/CustomVariableTest.php | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/library/Director/CustomVariable/CustomVariableDictionary.php b/library/Director/CustomVariable/CustomVariableDictionary.php index e7bea4e7a..de061f540 100644 --- a/library/Director/CustomVariable/CustomVariableDictionary.php +++ b/library/Director/CustomVariable/CustomVariableDictionary.php @@ -68,7 +68,10 @@ public function getValue() { $ret = (object) array(); - foreach ($this->value as $key => $var) { + $sortedValue = $this->value; + ksort($sortedValue); + + foreach ($sortedValue as $key => $var) { $ret->$key = $var->getValue(); } diff --git a/test/php/library/Director/CustomVariable/CustomVariableTest.php b/test/php/library/Director/CustomVariable/CustomVariableTest.php index 8c2fe65fc..37bbeb558 100644 --- a/test/php/library/Director/CustomVariable/CustomVariableTest.php +++ b/test/php/library/Director/CustomVariable/CustomVariableTest.php @@ -292,6 +292,14 @@ public function testDictionaryKeyOrderDoesNotMatter(): void $this->assertTrue($a->equals($b)); } + public function testGetDbValueSerializesKeysInSortedOrder(): void + { + $dict = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); + assert($dict instanceof CustomVariableDictionary); + + $this->assertSame('{"crit":"10%","warn":"20%"}', $dict->getDbValue()); + } + public function testDictionariesWithDifferentKeysAreNotEqual(): void { $a = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); From cff890cbb87428628fbcda56b512c112ff9d262a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 10 Jul 2026 09:33:55 +0200 Subject: [PATCH 101/221] MigrateCommand: Don't count unsupported-type datafields as migrated The summary counted every datafield left after dedup, including ones skipped for having an unsupported type, inflating the migrated total and undercounting the skipped total by the same amount. --- application/clicommands/MigrateCommand.php | 8 ++++++- .../Director/Objects/MigrateCommandTest.php | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index 76ac915c2..319dc3e67 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -102,7 +102,13 @@ public function datafieldsAction() echo "Migration completed\n"; - $totalMigrated = count($customPropertiesToMigrate); + $totalMigrated = 0; + foreach ($customPropertiesToMigrate as $customProperty) { + if (! str_starts_with($customProperty['value_type'], 'unsupported-')) { + $totalMigrated++; + } + } + $totalSkipped = count(DirectorDatafield::loadAll($db)) - $totalMigrated; if ($delete) { if (! $dryRun) { diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index ffb176309..e0e666f2e 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -430,6 +430,30 @@ public function testUnsupportedTimeTypeIsSkippedEvenWithoutVerbose(): void $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(); + + // MIGRATABLE (5) + env_choices_default_behavior (added for the datalist default- + // behavior fix) = 6 datafields actually get a director_property row. VAR_TIME_FIELD + // is present in the fixture set but has an unsupported type and must not be counted + // as migrated. + $expectedMigrated = count(self::MIGRATABLE) + 1; + $this->assertStringContainsString( + "Total 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()) { From b4a5dc4e79ace91abcba5d7056f4a03e44673ad7 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 10 Jul 2026 09:46:26 +0200 Subject: [PATCH 102/221] CustomVariables: Make the dynamic-dictionary merge cache deterministic prefetchCustomVarTypes() had no ORDER BY and let whichever row came back last win, so the object owning a dynamic-dictionary var could flip depending on the database's row order. Now a row matching the object actually being rendered always wins over an ancestor's row. --- .../CustomVariable/CustomVariables.php | 12 ++- .../CustomVariable/CustomVariablesTest.php | 95 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php index 08e60ae8f..ccc247b68 100644 --- a/library/Director/CustomVariable/CustomVariables.php +++ b/library/Director/CustomVariable/CustomVariables.php @@ -61,7 +61,7 @@ protected function prefetchCustomVarTypes(IcingaObject $object): void } $type = $object->getShortTableName(); - $objectId = $object->get('id'); + $objectId = (int) $object->get('id'); $ids = $object->listAncestorIds(); $ids[] = $objectId; @@ -76,6 +76,16 @@ protected function prefetchCustomVarTypes(IcingaObject $object): void ->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, diff --git a/test/php/library/Director/CustomVariable/CustomVariablesTest.php b/test/php/library/Director/CustomVariable/CustomVariablesTest.php index 36f5eb81e..592b91331 100644 --- a/test/php/library/Director/CustomVariable/CustomVariablesTest.php +++ b/test/php/library/Director/CustomVariable/CustomVariablesTest.php @@ -6,7 +6,11 @@ namespace Tests\Icinga\Module\Director\CustomVariable; use Icinga\Module\Director\CustomVariable\CustomVariables; +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 CustomVariablesTest extends BaseTestCase { @@ -67,6 +71,97 @@ public function testVariablesToExpression() $this->assertEquals($expected, $vars->toConfigString(true)); } + public function testPrefetchCustomVarTypesPrefersExactObjectMatchOverAncestorRows(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // Template needs at least one icinga_host_var row to satisfy the JOIN in + // CustomVariables::prefetchCustomVarTypes(). + $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_mount_thresholds', + 'value_type' => 'dynamic-dictionary', + 'label' => 'Disk Mount Thresholds', + ], $db); + $property->store(); + + $child = IcingaHost::create([ + 'object_name' => '___TEST___db-server-02', + 'object_type' => 'object', + 'address' => '10.0.1.55', + 'vars' => [ + '___TEST___disk_mount_thresholds' => (object) [ + 'root' => (object) ['mount_point' => '/', 'warn' => '20%', 'crit' => '10%'], + ], + ], + ], $db); + $child->imports = '___TEST___linux-server'; + $child->store(); + + // Assign the same property to both the template and the child, so the query in + // prefetchCustomVarTypes() returns two rows for this key: one with object_id equal + // to the ancestor template's id, one equal to the child's own id. Which row the + // database happens to return last must not decide which object_id gets cached. + $db->insert('icinga_host_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($property->get('uuid'), $dba), + 'host_uuid' => DbUtil::quoteBinaryCompat($template->get('uuid'), $dba), + ]); + $db->insert('icinga_host_property', [ + 'property_uuid' => DbUtil::quoteBinaryCompat($property->get('uuid'), $dba), + 'host_uuid' => DbUtil::quoteBinaryCompat($child->get('uuid'), $dba), + ]); + + $loaded = IcingaHost::load('___TEST___db-server-02', $db); + $vars = $loaded->vars(); + + self::callMethod($vars, 'prefetchCustomVarTypes', [$loaded]); + + $cacheProperty = new \ReflectionProperty(CustomVariables::class, 'cachedCustomVariableTypes'); + $cacheProperty->setAccessible(true); + $cache = $cacheProperty->getValue($vars); + + $this->assertSame( + (int) $loaded->get('id'), + $cache['___TEST___disk_mount_thresholds']['object_id'], + 'the exact object being rendered must win over an ancestor row for the same key, ' + . 'regardless of the order the database returns rows in' + ); + } + + public function tearDown(): void + { + if ($this->hasDb()) { + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // Child must be deleted before the template it imports from. + foreach (['___TEST___db-server-02', '___TEST___linux-server'] as $name) { + if (IcingaHost::exists($name, $db)) { + IcingaHost::load($name, $db)->delete(); + } + } + + $dba->delete( + 'director_property', + $dba->quoteInto('key_name = ?', '___TEST___disk_mount_thresholds') + ); + } + + parent::tearDown(); + } + protected function indentVarsList($vars) { return $this->indent . implode( From abc71dc8b32692ed2faff98649b98659a00541f1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 09:27:19 +0200 Subject: [PATCH 103/221] CustomVarRenderer: Fix crash on scalar fields inside dictionaries renderDictionaryVal() used the outer dictionary's own field name ($key) instead of the current loop variable ($k) when rendering a nested array or a plain scalar leaf. For a scalar leaf this caused renderCustomVarValue() to recurse into itself with the same key/value forever, crashing with a stack overflow for any dictionary containing a plain scalar field. --- .../Director/ProvidedHook/Icingadb/CustomVarRenderer.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index 95bcbc912..c425d43be 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -570,14 +570,14 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml $this->dictionaryLevel--; } elseif (is_array($val)) { - $this->renderArrayVal($key, $val, $key); + $this->renderArrayVal($k, $val, $k); } else { $this->dictionaryBody->addHtml( new HtmlElement( 'tr', Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), - new HtmlElement('th', null, $this->renderCustomVarKey($key) ?? Html::wantHtml($key)), - new HtmlElement('td', null, $this->renderCustomVarValue($key, $val) ?? Html::wantHtml($val)) + new HtmlElement('th', null, $this->renderCustomVarKey($k) ?? Html::wantHtml($k)), + new HtmlElement('td', null, $this->renderCustomVarValue($k, $val) ?? Html::wantHtml($val)) ) ); } From 7431fc6e2fa0c397dbfc5616387de80eb29d83d5 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 09:27:33 +0200 Subject: [PATCH 104/221] CustomVarRenderer: Mask sensitive properties in the read view The Icinga DB custom-variable detail view had no notion of the new 'sensitive' value type and rendered such values in plaintext. Mask them the same way legacy hidden strings already are: top-level and dictionary-nested sensitive properties render as '***', and sensitive items nested inside a fixed-/dynamic-array (which are individually typed, unlike dynamic-array/datalist) are masked by tracking their position within the array. --- .../Icingadb/CustomVarRenderer.php | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index c425d43be..075812679 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -46,6 +46,14 @@ class CustomVarRenderer extends CustomVarRendererHook /** @var array Related dictionary field names */ protected $customPropertyDictionaries = []; + /** + * Positions of sensitive items within fixed-/dynamic-array custom properties, keyed by + * the array's own key_name and then by item position, e.g. ['ssh_args' => ['3' => true]] + * + * @var array + */ + protected $sensitiveArrayItems = []; + protected $dictionaryLevel = 0; /** @var HtmlElement Table for dictionary fields */ @@ -264,6 +272,10 @@ public function prefetchForObject(Model $object): bool $this->customVariableConfig[$propertyName]['group'] = $customProperty['category']; } + if ($customProperty['value_type'] === 'sensitive') { + $this->customVariableConfig[$propertyName]['visibility'] = 'hidden'; + } + if (str_starts_with($customProperty['value_type'], 'datalist-')) { $customPropertiesWithDatalists[$customProperty['uuid']] = $customProperty; } elseif (str_ends_with($customProperty['value_type'], '-dictionary')) { @@ -291,6 +303,9 @@ public function prefetchForObject(Model $object): bool = $dictionaryItem->label; if (is_string($propertyName)) { $this->customVariableConfig[$propertyName] = ['label' => $dictionaryItem->label]; + if ($dictionaryItem->value_type === 'sensitive') { + $this->customVariableConfig[$propertyName]['visibility'] = 'hidden'; + } } if (str_starts_with($dictionaryItem->value_type, 'datalist-')) { @@ -298,6 +313,30 @@ public function prefetchForObject(Model $object): bool } } + // Unlike dynamic-array (one item_type for all elements) and datalist item types, + // fixed-array items are individually typed, so a single fixed-array can carry a + // 'sensitive' item at one position and plain strings/numbers at others. Track + // which positions are sensitive, scoped by the array's own key_name, so two + // different fixed-arrays sharing the same positional key_names (e.g. both having + // a "0", "1", ...) don't get mixed up. + $sensitiveArrayItems = $db->select()->from( + ['dpp' => 'director_property'], + [] + ) + ->join(['dpc' => 'director_property'], 'dpp.uuid = dpc.parent_uuid', []) + ->columns([ + 'parent_name' => 'dpp.key_name', + 'key_name' => 'dpc.key_name', + ]) + ->where('dpp.value_type', '*-array') + ->where('dpc.value_type', 'sensitive'); + + foreach ($sensitiveArrayItems as $sensitiveArrayItem) { + if (is_string($sensitiveArrayItem->parent_name)) { + $this->sensitiveArrayItems[$sensitiveArrayItem->parent_name][$sensitiveArrayItem->key_name] = true; + } + } + $dataListEntries = $db->select()->from( ['dpd' => 'director_property_datalist'], [ @@ -443,10 +482,16 @@ public function renderCustomVarValue(string $key, $value) return '***'; } + if (($this->customVariableConfig[$key]['visibility'] ?? null) === 'hidden') { + return '***'; + } + if (is_array($value) && ! isset($this->customPropertyDictionaries[$key])) { $renderedValue = []; foreach ($value as $k => $v) { - if (is_string($v) && isset($this->datalistMaps[$key][$v])) { + if (isset($this->sensitiveArrayItems[$key][$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]"]), @@ -526,6 +571,14 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml 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) ?? $val; + } + $val = (array) $val; $numChildItems = count($val); From 75adc7d19ad6c85cd0cdc7d4d256cf5af8ffadcf Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 09:28:08 +0200 Subject: [PATCH 105/221] CustomVariables: Add a 'sensitive' value type Give director_property an equivalent to the legacy visibility=hidden string setting: a value type that stores plaintext (no crypto/key-mgmt scope change) but is masked wherever it's shown to users. This adds it to the schema enum and makes it selectable as a top-level property type and as a nested field inside fixed-array/fixed-dictionary/dynamic-dictionary. It's deliberately not offered as a dynamic-array or datalist item type, since both render their values as a flat, unmasked list with no per-item masking mechanism. --- application/forms/CustomVariableForm.php | 1 + schema/mysql-migrations/upgrade_193.sql | 1 + schema/mysql.sql | 1 + schema/pgsql-migrations/upgrade_193.sql | 1 + schema/pgsql.sql | 1 + 5 files changed, 5 insertions(+) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index 78a193557..f51c1d5bc 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -183,6 +183,7 @@ protected function assemble(): void 'string' => 'String', 'number' => 'Number', 'bool' => 'Boolean', + 'sensitive' => 'Sensitive', 'dynamic-array' => 'Dynamic Array', 'datalist-strict' => 'Data List Strict', 'datalist-non-strict' => 'Data List Non Strict', diff --git a/schema/mysql-migrations/upgrade_193.sql b/schema/mysql-migrations/upgrade_193.sql index 4f1ee875d..d61a20c6c 100644 --- a/schema/mysql-migrations/upgrade_193.sql +++ b/schema/mysql-migrations/upgrade_193.sql @@ -7,6 +7,7 @@ CREATE TABLE director_property ( 'string', 'number', 'bool', + 'sensitive', 'fixed-array', 'dynamic-array', 'fixed-dictionary', diff --git a/schema/mysql.sql b/schema/mysql.sql index 1bf1f12d6..167577700 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -258,6 +258,7 @@ CREATE TABLE director_property ( 'string', 'number', 'bool', + 'sensitive', 'fixed-array', 'dynamic-array', 'fixed-dictionary', diff --git a/schema/pgsql-migrations/upgrade_193.sql b/schema/pgsql-migrations/upgrade_193.sql index 2a6130c7c..7c3edfb62 100644 --- a/schema/pgsql-migrations/upgrade_193.sql +++ b/schema/pgsql-migrations/upgrade_193.sql @@ -2,6 +2,7 @@ CREATE TYPE enum_property_value_type AS ENUM( 'string', 'number', 'bool', + 'sensitive', 'fixed-array', 'dynamic-array', 'fixed-dictionary', diff --git a/schema/pgsql.sql b/schema/pgsql.sql index bc4074f23..a4c4a1ed2 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -21,6 +21,7 @@ CREATE TYPE enum_property_value_type AS ENUM( 'string', 'number', 'bool', + 'sensitive', 'fixed-array', 'dynamic-array', 'fixed-dictionary', From 391956ab82c51d85fa91aef20e08b0a9beaa15d5 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 09:28:18 +0200 Subject: [PATCH 106/221] MigrateCommand: Migrate hidden-visibility strings as 'sensitive' Legacy string datafields with visibility=hidden were treated as "protected" and skipped during migration entirely, since there was nowhere for them to go. Now that director_property has a 'sensitive' value type, migrate them into it instead of dropping them, and remove the now-obsolete protected-datafield detection and reporting. --- application/clicommands/MigrateCommand.php | 50 +++---------------- .../Director/Objects/MigrateCommandTest.php | 30 +++++++---- 2 files changed, 25 insertions(+), 55 deletions(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index 319dc3e67..19bd54d31 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -49,7 +49,6 @@ public function datafieldsAction() // Dry run summary if ($dryRun) { $this->checkMigrateableDatafieldTypes(); - $this->checkProtectedDatafields(); $this->checkDatafieldsWithCategory(); $this->checkUnmigrateableDatafieldTypes(); $this->checkDatafieldsWithDuplicateNames(); @@ -83,10 +82,6 @@ public function datafieldsAction() echo "[-] Skipping migrating datafield '$varname' as it belongs to a category\n"; } - foreach ($this->getDatafieldsWithProtectedValues() as $varname) { - echo "[-] Skipping migrating datafield '$varname' as it is protected\n"; - } - foreach ($this->getDatafieldsWithDuplicateNames() as $varname => $count) { printf( "[-] Skipping migrating datafield '%s' as there are '%d' datafields with same name\n", @@ -165,8 +160,13 @@ private function prepareCustomProperties(): array if ($dataType === 'array') { $customProperty['value_type'] = 'dynamic-array'; $customProperty['item_type'] = 'string'; - } elseif ($dataType === 'boolean' || $dataType === 'number' || $dataType === '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(); @@ -346,7 +346,6 @@ private function getDataFieldsMigrationQuery(): DbQuery $skippedFields = array_merge( array_keys($this->getDatafieldsWithDuplicateNames()), array_keys($this->getDatafieldsWithUnsupportedValuetype()), - $this->getDatafieldsWithProtectedValues(), $this->getDatafieldsWithCategory() ); @@ -388,20 +387,6 @@ private function checkDatafieldsWithDuplicateNames(): void printf("Total datafields that can not be migrated because of having duplicates: %d\n\n", $total); } - /** - * Check what datafields can not be migrated because they are protected - * - * @return void - */ - private function checkProtectedDatafields(): void - { - $count = count($this->getDatafieldsWithProtectedValues()); - - if ($count > 0) { - printf("The following number of datafields are protected and can not be migrated: %d\n\n", $count); - } - } - /** * Get query for datafields * @@ -457,29 +442,6 @@ private function getDatafieldsWithDuplicateNames(): array return $query->fetchPairs(); } - /** - * Get datafields with protected values - * - * @return array - */ - private function getDatafieldsWithProtectedValues(): array - { - $query = $this->getDataFieldQuery(); - $query->joinLeft( - ['dds' => 'director_datafield_setting'], - "dd.id = dds.datafield_id AND dds.setting_name = 'visibility'", - [] - ); - $query->addFilter(Filter::matchAll( - FilterMatch::where('dd.datatype', '*String'), - FilterMatch::where('dds.setting_value', 'hidden') - ))->addFilter(Filter::fromQueryString('category_id IS NULL')); - - $query->columns(['varname']); - - return $query->fetchColumn(); - } - /** * Get datafields with categories * diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index e0e666f2e..847733776 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -38,7 +38,8 @@ class MigrateCommandTest extends BaseTestCase private const VAR_CATEGORIZED = self::PREFIX . 'categorized_field'; - private const VAR_HIDDEN = self::PREFIX . 'hidden_field'; + // Migratable as 'sensitive' (legacy hidden-visibility string) + private const VAR_HIDDEN = self::PREFIX . 'snmp_community'; private const VAR_DUP = self::PREFIX . 'dup_field'; @@ -56,6 +57,7 @@ class MigrateCommandTest extends BaseTestCase self::VAR_CHECK_INTERVAL, self::VAR_ENV_CHOICES, self::VAR_ENV_SUGGEST, + self::VAR_HIDDEN, ]; private const ALL_TEST_VARS = [ @@ -371,7 +373,7 @@ public function testCategorizedDatafieldIsSkipped(): void ); $this->assertEquals(0, (int) $count, 'Categorized datafield must not be migrated'); } - public function testProtectedStringFieldIsSkipped(): void + public function testHiddenStringFieldMigratesAsSensitive(): void { if ($this->skipForMissingDb()) { return; @@ -384,10 +386,16 @@ public function testProtectedStringFieldIsSkipped(): void $cmd->runDatafields(); $dba = $db->getDbAdapter(); - $count = $dba->fetchOne( - $dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', self::VAR_HIDDEN) + $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' ); - $this->assertEquals(0, (int) $count, 'Protected (hidden visibility) datafield must not be migrated'); } public function testUnsupportedTypeIsSkipped(): void @@ -442,10 +450,10 @@ public function testTotalMigratedCountExcludesUnsupportedTypes(): void $cmd = new TestableMigrateCommand($db); $output = $cmd->runDatafields(); - // MIGRATABLE (5) + env_choices_default_behavior (added for the datalist default- - // behavior fix) = 6 datafields actually get a director_property row. VAR_TIME_FIELD - // is present in the fixture set but has an unsupported type and must not be counted - // as migrated. + // MIGRATABLE (6, including the hidden-string field that now migrates as 'sensitive') + // + env_choices_default_behavior (added for the datalist default-behavior fix) = 7 + // datafields actually get a director_property row. VAR_TIME_FIELD is present in the + // fixture set but has an unsupported type and must not be counted as migrated. $expectedMigrated = count(self::MIGRATABLE) + 1; $this->assertStringContainsString( "Total datafields migrated: $expectedMigrated\n", @@ -748,10 +756,10 @@ private function createAllFixtures(Db $db): void ], $db); $field->store(); - // 8. hidden_field — protected string (visibility=hidden, skip) + // 8. snmp_community — string with visibility=hidden, migrates as 'sensitive' $field = DirectorDatafield::create([ 'varname' => self::VAR_HIDDEN, - 'caption' => 'Hidden Field', + 'caption' => 'SNMP Community String', 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', ], $db); $field->set('visibility', 'hidden'); From adf4b4456bce1051016064d4cf6d97c58feb53a5 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 09:28:28 +0200 Subject: [PATCH 107/221] DictionaryItem: Render 'sensitive' fields with a masked value widget Add a SensitiveElement (a PasswordElement that coerces a null value to an empty string, matching how IplBoolean already self-contains its own value coercion) and wire it into DictionaryItem so 'sensitive' fields get a masked input instead of a plain text box, both at the top level and when nested inside fixed-array/fixed-dictionary/dynamic-dictionary. --- .../DictionaryElements/DictionaryItem.php | 30 ++++- .../Web/Form/Element/SensitiveElement.php | 18 +++ .../Director/Form/CustomVariableFormTest.php | 30 +++++ .../DictionaryElements/DictionaryItemTest.php | 118 ++++++++++++++++++ .../Lib/TestableDictionaryItem.php | 43 +++++++ .../Web/Form/Element/SensitiveElementTest.php | 27 ++++ 6 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 library/Director/Web/Form/Element/SensitiveElement.php create mode 100644 test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php create mode 100644 test/php/library/Director/Form/DictionaryElements/Lib/TestableDictionaryItem.php create mode 100644 test/php/library/Director/Web/Form/Element/SensitiveElementTest.php diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index 0f90e5a9f..633fcc799 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -8,6 +8,7 @@ use Icinga\Module\Director\Forms\Validator\DatalistEntryValidator; use Icinga\Module\Director\Web\Form\Element\ArrayElement; use Icinga\Module\Director\Web\Form\Element\IplBoolean; +use Icinga\Module\Director\Web\Form\Element\SensitiveElement; use ipl\Html\Attributes; use ipl\Html\Contract\FormElement; use ipl\Html\FormElement\FieldsetElement; @@ -139,6 +140,16 @@ protected function assemble(): void ['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() @@ -354,6 +365,13 @@ public static function prepare(array $property): array $values['var'] = $value; $values['var-search'] = $value; } + } elseif ($property['value_type'] === 'sensitive') { + $values['var'] = $property['value'] ?? ''; + // 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'] ?? ''; @@ -471,21 +489,23 @@ public function getItem(): array ) { $values['value'] = $this->getElement('var-search')->getValue(); } else { + $type = $this->getElement('type')->getValue(); + if (! empty($this->getElement('inherited')->getValue())) { $values['value'] = $itemValue->getValue(); } else { $defaultValue = null; - // Use the default value for fixed-array items only if the fixed array does not have an inherited value + // Use the default value for fixed-array items only if the fixed array does not have an + // inherited value. if ($this->getElement('parent_type')->getValue() === 'fixed-array') { - $type = $this->getElement('type')->getValue(); - $itemType = self::fetchItemType(Uuid::fromBytes($this->fields['uuid'])); match ($type) { - 'string' => $defaultValue = '', + 'string', 'sensitive' => $defaultValue = '', 'number' => $defaultValue = 0, 'bool' => $defaultValue = 'n', 'fixed-array', 'dynamic-array' => $defaultValue = [], - 'datalist-strict', 'datalist-non-strict' => $defaultValue = $itemType === 'string' ? '' : [], + 'datalist-strict', 'datalist-non-strict' => $defaultValue = + self::fetchItemType(Uuid::fromBytes($this->fields['uuid'])) === 'string' ? '' : [], default => $defaultValue = null }; } diff --git a/library/Director/Web/Form/Element/SensitiveElement.php b/library/Director/Web/Form/Element/SensitiveElement.php new file mode 100644 index 000000000..2e95462db --- /dev/null +++ b/library/Director/Web/Form/Element/SensitiveElement.php @@ -0,0 +1,18 @@ +assertNotNull($form->getUUid(), 'form UUID should be set after creation'); } + public function testAddSensitivePropertyCreatesRow(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $form = new TestableCustomVariableForm($db); + $form->setTestValues([ + 'key_name' => '___TEST___api_token', + 'value_type' => 'sensitive', + 'label' => 'API Token', + 'description' => 'Bearer token used to authenticate against the monitored API', + ]); + $this->createdKeyNames[] = '___TEST___api_token'; + + self::callMethod($form, 'onSuccess', []); + + $dba = $db->getDbAdapter(); + $row = $dba->fetchRow( + $dba->select() + ->from('director_property', ['key_name', 'value_type']) + ->where('key_name = ?', '___TEST___api_token') + ); + + $this->assertNotFalse($row, 'director_property row should be created'); + $this->assertSame('sensitive', $row->value_type); + $this->assertNotNull($form->getUUid(), 'form UUID should be set after creation'); + } + public function testAddDynamicArrayPropertyCreatesParentAndChildRows(): void { if ($this->skipForMissingDb()) { 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..cbc9e300d --- /dev/null +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -0,0 +1,118 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form\DictionaryElements; + +use Icinga\Module\Director\Forms\DictionaryElements\DictionaryItem; +use PHPUnit\Framework\TestCase; +use Tests\Icinga\Module\Director\Form\DictionaryElements\Lib\TestableDictionaryItem; + +class DictionaryItemTest extends TestCase +{ + public function testPrepareScrubsInheritedSecretForSensitiveType(): 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']); + } + + public function testPreparePreservesInheritedPresenceForSensitiveType(): 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->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 testGetItemDefaultsSensitiveValueToEmptyStringInFixedArray(): void + { + $item = new TestableDictionaryItem('0', []); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-array', + ]); + $item->ensureAssembled(); + + $this->assertSame('', $item->getItem()['value']); + } + + public function testGetItemDefaultsSensitiveValueToEmptyStringInFixedDictionary(): void + { + // A sensitive field nested directly under a fixed-dictionary (not a fixed-array) + // must still default to '', not null. + $item = new TestableDictionaryItem('api_token', []); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-dictionary', + ]); + $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 + { + // A parent template's own ssh_args has a non-empty value at this sensitive slot + // (e.g. "afafaf"), so 'inherited' is truthy here. The user leaves the field blank, + // accepting/ignoring the inherited value rather than typing an override. The + // overriding array must still store '' at this position, not a stray null. + $item = new TestableDictionaryItem('3', []); + $item->setTestConfig([ + 'type' => 'sensitive', + 'parent_type' => 'fixed-array', + 'inherited' => '1', + ]); + $item->ensureAssembled(); + + $this->assertSame('', $item->getItem()['value']); + } +} diff --git a/test/php/library/Director/Form/DictionaryElements/Lib/TestableDictionaryItem.php b/test/php/library/Director/Form/DictionaryElements/Lib/TestableDictionaryItem.php new file mode 100644 index 000000000..075b8f4d3 --- /dev/null +++ b/test/php/library/Director/Form/DictionaryElements/Lib/TestableDictionaryItem.php @@ -0,0 +1,43 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Director\Form\DictionaryElements\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/Web/Form/Element/SensitiveElementTest.php b/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php new file mode 100644 index 000000000..e256862f6 --- /dev/null +++ b/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php @@ -0,0 +1,27 @@ + +// 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('s3cr3t-value'); + + $this->assertSame('s3cr3t-value', $element->getValue()); + } +} From 1955675359fd7bdefe32b13659b33fc9731cd5fe Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 10:16:49 +0200 Subject: [PATCH 108/221] doc: Document the sensitive custom variable type Add sensitive to the custom variable type table, the nesting rules, and the per-type examples, and note that a hidden-visibility Data field now migrates as sensitive instead of being skipped. Also add a REST API example for setting a sensitive variable, and point out that GET still returns the real value since masking only happens in the web UI and the Icinga DB read view. --- doc/12-Handling-custom-variables.md | 30 +++++++++++++++++++++++------ doc/70-REST-API.md | 13 +++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/doc/12-Handling-custom-variables.md b/doc/12-Handling-custom-variables.md index 16b7630f6..e98eae282 100644 --- a/doc/12-Handling-custom-variables.md +++ b/doc/12-Handling-custom-variables.md @@ -54,6 +54,7 @@ types: | `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 | @@ -64,13 +65,17 @@ types: > Only one level of nesting is allowed. The item type of a `fixed-array`, > `dynamic-array` or `fixed-dictionary`, and the fields inside a > `dynamic-dictionary`'s sub-dictionary, may only be scalar (`string`, -> `number`, `bool`) or datalist (`datalist-strict`, `datalist-non-strict`) -> types. A nested field can itself be an array of datalist values, but it -> can never be a `fixed-array`, `fixed-dictionary` or -> `dynamic-array`/`dynamic-dictionary`. Also, `dynamic-dictionary` can +> `number`, `bool`, `sensitive`) or datalist (`datalist-strict`, +> `datalist-non-strict`) types. A nested field can itself be an array of +> datalist values, but it can never be a `fixed-array`, `fixed-dictionary` +> or `dynamic-array`/`dynamic-dictionary`. 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. @@ -118,6 +123,20 @@ vars.ssl_verify = true 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" +``` + ##### `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. @@ -299,12 +318,11 @@ 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 -* the field is not marked as protected/hidden * no custom variable with the same key already exists | Data field type | Custom variable type | |------------------|-----------------------| -| `DataTypeString` | `string` | +| `DataTypeString` | `string`, or `sensitive` if the field's visibility was set to `hidden` | | `DataTypeNumber` | `number` | | `DataTypeBoolean` | `bool` | | `DataTypeArray` | `dynamic-array` (string items) | diff --git a/doc/70-REST-API.md b/doc/70-REST-API.md index 215e59bd1..ebec20b8d 100644 --- a/doc/70-REST-API.md +++ b/doc/70-REST-API.md @@ -285,6 +285,19 @@ body. { "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 From 518912ad00dbe393b7e77a273065684504c94c5e Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 10:43:06 +0200 Subject: [PATCH 109/221] Tests: Merge redundant sensitive-prepare test, fix blank line in MigrateCommandTest DictionaryItemTest had two tests building the identical fixture and calling prepare() just to check two properties of the same result, merged into one. MigrateCommandTest was missing a blank line between two test methods, which phpcs flags. --- .../DictionaryElements/DictionaryItemTest.php | 18 +----------------- .../Director/Objects/MigrateCommandTest.php | 1 + 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index cbc9e300d..feee8fb77 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -11,7 +11,7 @@ class DictionaryItemTest extends TestCase { - public function testPrepareScrubsInheritedSecretForSensitiveType(): void + public function testPrepareScrubsInheritedSecretButKeepsItsPresenceForSensitiveType(): void { $property = [ 'uuid' => '', @@ -26,22 +26,6 @@ public function testPrepareScrubsInheritedSecretForSensitiveType(): void $result = DictionaryItem::prepare($property); $this->assertStringNotContainsString('s3cr3t-inherited-value', $result['inherited']); - } - - public function testPreparePreservesInheritedPresenceForSensitiveType(): 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->assertNotEmpty($result['inherited'], 'presence of an inherited value must still be signaled'); } diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index 847733776..dc1b9f1ea 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -373,6 +373,7 @@ public function testCategorizedDatafieldIsSkipped(): void ); $this->assertEquals(0, (int) $count, 'Categorized datafield must not be migrated'); } + public function testHiddenStringFieldMigratesAsSensitive(): void { if ($this->skipForMissingDb()) { From a3ec1c2a4ce5f0ab70523cae441ed8be195b2409 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 10:48:16 +0200 Subject: [PATCH 110/221] DictionaryItem: Wrap long fetchChildrenItems signature --- application/forms/DictionaryElements/DictionaryItem.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index 633fcc799..e48ec04fc 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -390,8 +390,11 @@ public static function prepare(array $property): array * * @return array */ - private static function fetchChildrenItems(UuidInterface $parentUuid, string $parentType, array $values = []): array - { + private static function fetchChildrenItems( + UuidInterface $parentUuid, + string $parentType, + array $values = [] + ): array { $db = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); $query = $db->select() From be4bd554d2a6d13bc69899686440a7db0e6e2eff Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 10:48:53 +0200 Subject: [PATCH 111/221] DictionaryItem: Tidy up key order sorting comment for fixed-array --- application/forms/DictionaryElements/DictionaryItem.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index e48ec04fc..213d90267 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -422,7 +422,7 @@ private static function fetchChildrenItems( $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 field name, so order by + // 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']); } From c1206fe4a1155cffe4b0d75de1a1c347870f3be8 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 13:07:12 +0200 Subject: [PATCH 112/221] test: Clean up tests --- .../CustomVariableDictionaryRenderingTest.php | 27 -------- .../CustomVariable/CustomVariableTest.php | 13 ---- .../Director/Form/CustomVariableFormTest.php | 30 --------- .../Director/Form/CustomVariablesFormTest.php | 8 --- .../DictionaryElements/DictionaryItemTest.php | 14 ----- .../IcingaConfig/IcingaConfigHelperTest.php | 8 --- .../Director/Objects/DirectorPropertyTest.php | 61 ++++++------------- .../Director/Objects/MigrateCommandTest.php | 34 ----------- 8 files changed, 20 insertions(+), 175 deletions(-) diff --git a/test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php b/test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php index d2a98755d..a35c44511 100644 --- a/test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php +++ b/test/php/library/Director/CustomVariable/CustomVariableDictionaryRenderingTest.php @@ -91,20 +91,6 @@ public function testDynamicDictionaryRendersWithPlusEquals(): void $this->assertEquals($expected, $vars->toConfigString()); } - public function testDynamicDictionaryRendersWithEqualsWhenNotOverride(): void - { - $vars = new CustomVariables(); - $vars->disk_checks = ['warn' => '20%', 'crit' => '10%']; - // no setOverrideKeyName call — ordinary assignment - - $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(); @@ -142,17 +128,4 @@ public function testApplyForWhitelistStripsUnknownValueMacro(): void $vars->toConfigString(true) ); } - - public function testDatalistValueRendersAsString(): void - { - $vars = new CustomVariables(); - // A var whose value comes from a datalist is stored as a plain string; the - // datalist constraint is a UI concern only, not reflected in config rendering - $vars->env = 'production'; - - $this->assertEquals( - $this->indent . 'vars.env = "production"' . "\n", - $vars->toConfigString() - ); - } } diff --git a/test/php/library/Director/CustomVariable/CustomVariableTest.php b/test/php/library/Director/CustomVariable/CustomVariableTest.php index 37bbeb558..363ee55fb 100644 --- a/test/php/library/Director/CustomVariable/CustomVariableTest.php +++ b/test/php/library/Director/CustomVariable/CustomVariableTest.php @@ -62,12 +62,6 @@ public function testCreateEmptyArrayReturnsArray(): void $this->assertInstanceOf(CustomVariableArray::class, CustomVariable::create('k', [])); } - public function testCreateNumericStringKeyedArrayReturnsArray(): void - { - // Arrays whose keys are numeric strings ('0', '1', …) are treated as arrays, not dictionaries - $this->assertInstanceOf(CustomVariableArray::class, CustomVariable::create('k', ['0' => 'x', '1' => 'y'])); - } - public function testCreateAssociativeArrayReturnsDictionary(): void { $this->assertInstanceOf(CustomVariableDictionary::class, CustomVariable::create('k', ['key' => 'val'])); @@ -270,13 +264,6 @@ public function testFromDbRowMarksVarAsUnmodified(): void // CustomVariableDictionary::equals() // ------------------------------------------------------------------------- - public function testDictionaryEqualsItself(): void - { - $dict = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); - assert($dict instanceof CustomVariableDictionary); - $this->assertTrue($dict->equals($dict)); - } - public function testEqualDictionariesAreEqual(): void { $a = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); diff --git a/test/php/library/Director/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php index ce7a42804..7974f4ca9 100644 --- a/test/php/library/Director/Form/CustomVariableFormTest.php +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -44,36 +44,6 @@ public function testAddStringPropertyCreatesRow(): void $this->assertNotNull($form->getUUid(), 'form UUID should be set after creation'); } - public function testAddSensitivePropertyCreatesRow(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $form = new TestableCustomVariableForm($db); - $form->setTestValues([ - 'key_name' => '___TEST___api_token', - 'value_type' => 'sensitive', - 'label' => 'API Token', - 'description' => 'Bearer token used to authenticate against the monitored API', - ]); - $this->createdKeyNames[] = '___TEST___api_token'; - - self::callMethod($form, 'onSuccess', []); - - $dba = $db->getDbAdapter(); - $row = $dba->fetchRow( - $dba->select() - ->from('director_property', ['key_name', 'value_type']) - ->where('key_name = ?', '___TEST___api_token') - ); - - $this->assertNotFalse($row, 'director_property row should be created'); - $this->assertSame('sensitive', $row->value_type); - $this->assertNotNull($form->getUUid(), 'form UUID should be set after creation'); - } - public function testAddDynamicArrayPropertyCreatesParentAndChildRows(): void { if ($this->skipForMissingDb()) { diff --git a/test/php/library/Director/Form/CustomVariablesFormTest.php b/test/php/library/Director/Form/CustomVariablesFormTest.php index 97fff30c1..5a4a66847 100644 --- a/test/php/library/Director/Form/CustomVariablesFormTest.php +++ b/test/php/library/Director/Form/CustomVariablesFormTest.php @@ -211,14 +211,6 @@ public function testEmptyArrayReturnsEmpty(): void $this->assertSame([], CustomVariablesForm::filterEmpty([])); } - public function testFixedArrayKeepsEmptyMiddleSlotInPlace(): void - { - // A fixed array's items are positional -- dropping the empty middle one would shift - // the third item into its place. - $result = CustomVariablesForm::filterEmpty(['a', '', 'c']); - $this->assertSame(['a', '', 'c'], $result); - } - public function testFixedArrayWithAllEmptySlotsIsDropped(): void { $result = CustomVariablesForm::filterEmpty(['', '', '']); diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index feee8fb77..3cc25f9cf 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -56,20 +56,6 @@ public function testGetItemDefaultsSensitiveValueToEmptyStringInFixedArray(): vo $this->assertSame('', $item->getItem()['value']); } - public function testGetItemDefaultsSensitiveValueToEmptyStringInFixedDictionary(): void - { - // A sensitive field nested directly under a fixed-dictionary (not a fixed-array) - // must still default to '', not null. - $item = new TestableDictionaryItem('api_token', []); - $item->setTestConfig([ - 'type' => 'sensitive', - 'parent_type' => 'fixed-dictionary', - ]); - $item->ensureAssembled(); - - $this->assertSame('', $item->getItem()['value']); - } - public function testGetItemPreservesEnteredSensitiveValue(): void { $item = new TestableDictionaryItem('api_token', []); diff --git a/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php index 4500479a6..8f1249257 100644 --- a/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php +++ b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php @@ -149,14 +149,6 @@ public function testIsValidMacroNameWithNoWhitelist() $this->assertFalse(c::isValidMacroName('')); } - public function testIsValidMacroNameNullWhitelistBehavesLikeNoWhitelist() - { - // Passing null explicitly is the same as omitting the argument - $this->assertTrue(c::isValidMacroName('host.vars.custom', null)); - $this->assertFalse(c::isValidMacroName('a', null)); - $this->assertFalse(c::isValidMacroName('value.', null)); - } - public function testIsValidMacroNameExactWhitelistMatch() { $this->assertTrue(c::isValidMacroName('value.path', ['value.path'])); diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 7beb2cf4a..712ff2b2e 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -115,35 +115,38 @@ public function testFixedDictionaryWithSubfields(): void $this->assertEquals(['crit', 'warn'], $childKeys); } - public function testDynamicDictionaryNestingIsOneLevelOnly(): void + public function testDynamicDictionaryNestingIsNotRestrictedByTheModel(): void { if ($this->skipForMissingDb()) { return; } + // The "dynamic-dictionary may only be a top-level property" rule is enforced + // entirely by CustomVariableForm's value_type dropdown, which only offers + // 'dynamic-dictionary' as an option when the field being added is neither + // nested nor itself a child (see CustomVariableForm.php's $types construction, + // gated on !$this->isNestedField && $this->parentUuid === null). DirectorProperty + // itself has no such restriction, so creating a 'dynamic-dictionary' child + // directly through the model succeeds. This pins that down so nobody mistakes + // the model for a backstop that isn't there. $db = $this->getDb(); $parent = $this->makeProperty('disk_checks', 'dynamic-dictionary', 'Disk Checks', $db); $parent->store(); $parentUuid = $parent->get('uuid'); - foreach (['mount_point', 'warn', 'crit'] as $fieldName) { - $child = DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => $fieldName, - 'parent_uuid' => $parentUuid, - 'value_type' => 'string', - ], $db); - $child->store(); - } + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'nested', + 'parent_uuid' => $parentUuid, + 'value_type' => 'dynamic-dictionary', + ], $db); + $child->store(); $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $db); - foreach ($reloaded->fetchItemsFromDb() as $child) { - $this->assertNotEquals( - 'dynamic-dictionary', - $child->get('value_type'), - "Child of dynamic-dictionary must not itself be dynamic-dictionary (one-level nesting rule)" - ); - } + $items = $reloaded->fetchItemsFromDb(); + + $this->assertCount(1, $items); + $this->assertEquals('dynamic-dictionary', $items[0]->get('value_type')); } public function testDatalistStrictAssociatesDatalist(): void @@ -164,30 +167,6 @@ public function testDatalistStrictAssociatesDatalist(): void $this->assertEquals($listName, $linked->get('list_name')); } - public function testDatalistNonStrictAssociatesDatalist(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $listName = self::PREFIX . 'env_suggest'; - $this->makeDatalist($listName, $db)->store(); - $property = $this->importPropertyWithDatalist( - 'env_suggest', - 'datalist-non-strict', - 'Env Suggest', - $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()) { diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index dc1b9f1ea..d6bc15c69 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -266,40 +266,6 @@ public function testDatalistStrictMigrationLinksDatalist(): void ); } - public function testDatalistNonStrictMigrationLinksDatalist(): 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_SUGGEST) - ); - $this->assertNotFalse($property, 'env_suggest 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-non-strict datafield must link the new property to its datalist' - ); - } - public function testDeleteOptionRemovesMigratedDatafields(): void { if ($this->skipForMissingDb()) { From d802e4eb77bb7744db4a1de6b0e8dedc882969c1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 13:25:49 +0200 Subject: [PATCH 113/221] DirectorProperty: Dynamic-dictionary type custom variables must not be nested more than one level Dynamic-dictionary type should not be allowed to be chosen as a child custom variable value_type. As the nesting has been blocked to one level currently. --- library/Director/Objects/DirectorProperty.php | 22 ++++++++++++++++++ .../Director/Objects/DirectorPropertyTest.php | 23 +++++++------------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index e9f04180a..3973ed875 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -7,6 +7,7 @@ use Icinga\Module\Director\Data\Db\DbObject; use Icinga\Module\Director\Db; use Icinga\Module\Director\DirectorObject\Automation\CompareBasketObject; +use InvalidArgumentException; use Ramsey\Uuid\Uuid; use stdClass; @@ -405,6 +406,27 @@ public static function import(stdClass $plain, Db $db): static return $property; } + /** Value types that may never be used for a nested (non-top-level) property */ + private const NON_NESTABLE_TYPES = ['dynamic-dictionary']; + + /** + * @throws InvalidArgumentException if a nested property is being stored with a value_type + * that may only be used at the top level + */ + protected function beforeStore(): void + { + if ( + $this->get('parent_uuid') !== null + && in_array($this->get('value_type'), 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 another dynamic-dictionary", + $this->get('value_type') + )); + } + } + protected function onStore(): void { $db = $this->db; diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 712ff2b2e..97c98a3d3 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -10,6 +10,7 @@ use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Objects\DirectorProperty; use Icinga\Module\Director\Test\BaseTestCase; +use InvalidArgumentException; use Ramsey\Uuid\Uuid; /** @@ -115,20 +116,16 @@ public function testFixedDictionaryWithSubfields(): void $this->assertEquals(['crit', 'warn'], $childKeys); } - public function testDynamicDictionaryNestingIsNotRestrictedByTheModel(): void + public function testDynamicDictionaryNestingIsRejectedByTheModel(): void { if ($this->skipForMissingDb()) { return; } - // The "dynamic-dictionary may only be a top-level property" rule is enforced - // entirely by CustomVariableForm's value_type dropdown, which only offers - // 'dynamic-dictionary' as an option when the field being added is neither - // nested nor itself a child (see CustomVariableForm.php's $types construction, - // gated on !$this->isNestedField && $this->parentUuid === null). DirectorProperty - // itself has no such restriction, so creating a 'dynamic-dictionary' child - // directly through the model succeeds. This pins that down so nobody mistakes - // the model for a backstop that isn't there. + // The "dynamic-dictionary may only be a top-level property" rule must hold + // regardless of entry point (form, REST API, CLI migration, basket restore), + // not just because CustomVariableForm's dropdown happens to never offer it as + // a nested option. DirectorProperty::beforeStore() enforces it directly. $db = $this->getDb(); $parent = $this->makeProperty('disk_checks', 'dynamic-dictionary', 'Disk Checks', $db); $parent->store(); @@ -140,13 +137,9 @@ public function testDynamicDictionaryNestingIsNotRestrictedByTheModel(): void 'parent_uuid' => $parentUuid, 'value_type' => 'dynamic-dictionary', ], $db); - $child->store(); - - $reloaded = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $db); - $items = $reloaded->fetchItemsFromDb(); - $this->assertCount(1, $items); - $this->assertEquals('dynamic-dictionary', $items[0]->get('value_type')); + $this->expectException(InvalidArgumentException::class); + $child->store(); } public function testDatalistStrictAssociatesDatalist(): void From bbfca13af7b1534c41e358c93bbe2860bb3f36f3 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 13 Jul 2026 14:39:28 +0200 Subject: [PATCH 114/221] DictionaryItem: Preserve sensitive values in nested and dynamic dictionaries Sensitive fields used to keep their value fine when they sat directly on an object, or one level deep inside a fixed-array or fixed-dictionary. But a sensitive field inside a dynamic-dictionary entry, or nested even deeper inside a fixed-dictionary within that entry, still lost its value on save. There was also a related bug where redisplaying the form for any reason other than a fresh page load, such as clicking Add Item, showed sensitive fields as blank instead of masked. That made it look like the value was gone, and a real save right after would have wiped it for good. Both are fixed by moving the masking decision into DictionaryItem::prepare(). Before, we relied on PasswordElement's own logic, but that needs form-submission state that never reaches elements nested this deep. --- .../DictionaryElements/DictionaryItem.php | 45 +- .../DictionaryElements/NestedDictionary.php | 18 +- .../Web/Form/Element/SensitiveElement.php | 55 ++- .../DictionaryElements/DictionaryItemTest.php | 459 +++++++++++++++++- .../Web/Form/Element/SensitiveElementTest.php | 67 +++ 5 files changed, 630 insertions(+), 14 deletions(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index 213d90267..403b59698 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -111,9 +111,13 @@ protected function assemble(): void } $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'] ?? '' + $this->fields['value_type'] ?? '', + ['value' => $this->fields['value'] ?? []] ); $inherited = $this->getElement('inherited')->getValue(); $inheritedFrom = $this->getElement('inherited_from')->getValue(); @@ -249,8 +253,9 @@ protected function assemble(): void $this->addElement((new NestedDictionary( $valElementName, $children, - ['inherited_from' => $inheritedFrom, 'value' => $inherited] - ))->setUuid(Uuid::fromBytes($this->fields['uuid'])))->setLabel($label . ' (Dictionary)'); + ['inherited_from' => $inheritedFrom, 'value' => $inherited], + $this->fields['value'] ?? [] + ))->setLabel($label . ' (Dictionary)')->setUuid(Uuid::fromBytes($this->fields['uuid']))); } else { $this->addElement( 'text', @@ -366,7 +371,10 @@ public static function prepare(array $property): array $values['var-search'] = $value; } } elseif ($property['value_type'] === 'sensitive') { - $values['var'] = $property['value'] ?? ''; + // 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. @@ -437,6 +445,24 @@ private static function fetchChildrenItems( 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; @@ -515,6 +541,17 @@ public function getItem(): array $values['value'] = $itemValue->getValue() ?? $defaultValue; } + + // If a sensitive field still has the DUMMYPASSWORD placeholder, keep the old + // secret. An empty value means the user cleared it on purpose. + if ( + $type === 'sensitive' + && $itemValue instanceof SensitiveElement + && $itemValue->wasSubmittedUnchanged() + && ! empty($this->fields['value']) + ) { + $values['value'] = $this->fields['value']; + } } $markForRemovalElement = 'delete-' . $this->getName(); diff --git a/application/forms/DictionaryElements/NestedDictionary.php b/application/forms/DictionaryElements/NestedDictionary.php index 24ae859a6..5bc3e4f7c 100644 --- a/application/forms/DictionaryElements/NestedDictionary.php +++ b/application/forms/DictionaryElements/NestedDictionary.php @@ -24,14 +24,19 @@ class NestedDictionary extends FieldsetElement /** @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); } @@ -125,7 +130,18 @@ protected function assemble(): void } for ($i = 0; $i < $newCount; $i++) { - $nestedDictionaryProperty = new NestedDictionaryItem($i, $this->nestedItems); + // 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); } diff --git a/library/Director/Web/Form/Element/SensitiveElement.php b/library/Director/Web/Form/Element/SensitiveElement.php index 2e95462db..f6dc7a7bd 100644 --- a/library/Director/Web/Form/Element/SensitiveElement.php +++ b/library/Director/Web/Form/Element/SensitiveElement.php @@ -2,17 +2,66 @@ namespace Icinga\Module\Director\Web\Form\Element; +use ipl\Html\Attributes; use ipl\Html\FormElement\PasswordElement; /** - * A password element that never surfaces null - an empty/untouched field - * evaluates to '', matching the value semantics of every other element type - * used for custom variables. + * A password field that never returns null. An empty or untouched field + * returns '', just like the other field types used for custom variables. */ class SensitiveElement extends PasswordElement { + /** + * Check whether this field was left unchanged + * + * When a user leaves this field as it is, the browser sends back the + * DUMMYPASSWORD placeholder instead of a real value. That is how we know + * the field was not cleared and not given a new value. + * + * We can't use PasswordElement's own getValue() for this. It relies on + * value candidates that get lost inside our nested forms (see + * DictionaryItem::getItem() for where and why). Reading the raw value here + * avoids that problem. + * + * @return bool + */ + public function wasSubmittedUnchanged(): bool + { + return $this->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/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index 3cc25f9cf..7e27ef33f 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -5,12 +5,26 @@ namespace Tests\Icinga\Module\Director\Form\DictionaryElements; +// icingaweb2's test autoloader only knows about Tests\Icinga\Module\Director\Lib +// (test/php/Lib). It can't find this class, since it sits one folder deeper, next to +// this test. So we load it by hand instead. +require_once __DIR__ . '/Lib/TestableDictionaryItem.php'; + +use Icinga\Module\Director\Forms\DictionaryElements\Dictionary; use Icinga\Module\Director\Forms\DictionaryElements\DictionaryItem; -use PHPUnit\Framework\TestCase; +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\DictionaryElements\Lib\TestableDictionaryItem; -class DictionaryItemTest extends TestCase +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 testPrepareScrubsInheritedSecretButKeepsItsPresenceForSensitiveType(): void { $property = [ @@ -44,6 +58,38 @@ public function testPrepareLeavesInheritedEmptyWhenNothingIsInheritedForSensitiv $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', []); @@ -71,10 +117,9 @@ public function testGetItemPreservesEnteredSensitiveValue(): void public function testGetItemDefaultsSensitiveValueToEmptyStringWhenInheritedAndLeftBlank(): void { - // A parent template's own ssh_args has a non-empty value at this sensitive slot - // (e.g. "afafaf"), so 'inherited' is truthy here. The user leaves the field blank, - // accepting/ignoring the inherited value rather than typing an override. The - // overriding array must still store '' at this position, not a stray null. + // 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', @@ -85,4 +130,406 @@ public function testGetItemDefaultsSensitiveValueToEmptyStringWhenInheritedAndLe $this->assertSame('', $item->getItem()['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 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']); + } + + public function testSensitiveFieldDirectlyInsideDynamicDictionaryEntryPreservesValueWhenLeftUnchanged(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(); + + $result = $dictionaryItem->getItem(); + + $this->assertSame('s3cr3t-disk-token', $result['value']['/var/log']['api_token']); + } + + public function testSensitiveFieldNestedAsGrandchildOfDynamicDictionaryPreservesValueWhenLeftUnchanged(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(); + + $result = $dictionaryItem->getItem(); + + $this->assertSame('s3cr3t-nested-token', $result['value']['/var/log']['disk_users']['token']); + } + + public function testSensitiveFieldsInsideDynamicDictionaryEntryAreClearedWhenExplicitlyEmptied(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(clearInsteadOfUnchanged: true); + + $result = $dictionaryItem->getItem(); + + $this->assertSame('', $result['value']['/var/log']['api_token']); + $this->assertSame('', $result['value']['/var/log']['disk_users']['token']); + } + + public function testSensitiveFieldsInsideDynamicDictionaryEntryKeepANewlyTypedValue(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(retypedValue: 'freshly-typed-value'); + + $result = $dictionaryItem->getItem(); + + $this->assertSame('freshly-typed-value', $result['value']['/var/log']['api_token']); + $this->assertSame('freshly-typed-value', $result['value']['/var/log']['disk_users']['token']); + } + + /** + * Build a dynamic-dictionary DictionaryItem ("disk_thresholds") backed by real + * director_property rows. It has one sensitive field directly on each entry + * ("api_token") and one nested one level deeper ("disk_users.token"). Loads and + * populates it the same way the controller does, so the sensitive fields are + * submitted as either the DUMMYPASSWORD placeholder (untouched), an empty string + * (cleared), or a new value, never the real secret. + */ + private function buildDiskThresholdsDictionaryItem( + bool $clearInsteadOfUnchanged = false, + ?string $retypedValue = null + ): DictionaryItem { + $db = $this->getDb(); + $parentUuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'disk_thresholds'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'dynamic-dictionary', + 'label' => 'Disk Thresholds', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'threshold', + 'parent_uuid' => $parentUuidBytes, + 'value_type' => 'number', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'api_token', + 'parent_uuid' => $parentUuidBytes, + 'value_type' => 'sensitive', + ], $db)->store(); + + $diskUsersUuidBytes = Uuid::uuid4()->getBytes(); + DirectorProperty::create([ + 'uuid' => $diskUsersUuidBytes, + 'key_name' => 'disk_users', + 'parent_uuid' => $parentUuidBytes, + 'value_type' => 'fixed-dictionary', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'team', + 'parent_uuid' => $diskUsersUuidBytes, + 'value_type' => 'string', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'token', + 'parent_uuid' => $diskUsersUuidBytes, + 'value_type' => 'sensitive', + ], $db)->store(); + + $property = [ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'dynamic-dictionary', + 'label' => 'Disk Thresholds', + 'value' => [ + '/var/log' => [ + 'threshold' => 5, + 'api_token' => 's3cr3t-disk-token', + 'disk_users' => [ + 'team' => 'ops', + 'token' => 's3cr3t-nested-token', + ], + ], + ], + ]; + + $preparedValues = DictionaryItem::prepare($property); + + $dictionaryItem = new DictionaryItem('0', $property); + $dictionaryItem->populate($preparedValues); + $submittedValues = match (true) { + $retypedValue !== null => $this->retypeSensitiveFields($preparedValues, $retypedValue), + $clearInsteadOfUnchanged => $this->clearSensitiveFields($preparedValues), + default => $this->markSensitiveFieldsUnchanged($preparedValues), + }; + $dictionaryItem->populate($submittedValues); + $dictionaryItem->ensureAssembled(); + + return $dictionaryItem; + } + + /** + * Replace every sensitive field's value with the DUMMYPASSWORD placeholder, like a + * browser resubmitting an untouched field. + */ + private function markSensitiveFieldsUnchanged(array $node): array + { + if (($node['type'] ?? null) === 'sensitive') { + $node['var'] = SensitiveElement::DUMMYPASSWORD; + } + + foreach ($node as $key => $value) { + if (is_array($value)) { + $node[$key] = $this->markSensitiveFieldsUnchanged($value); + } + } + + return $node; + } + + /** + * Replace every sensitive field's value with an empty string, like a browser + * submitting a field the user cleared. + */ + private function clearSensitiveFields(array $node): array + { + if (($node['type'] ?? null) === 'sensitive') { + $node['var'] = ''; + } + + foreach ($node as $key => $value) { + if (is_array($value)) { + $node[$key] = $this->clearSensitiveFields($value); + } + } + + return $node; + } + + /** + * Replace every sensitive field's value with a new value, like a browser + * submitting a field the user just changed. + */ + private function retypeSensitiveFields(array $node, string $newValue): array + { + if (($node['type'] ?? null) === 'sensitive') { + $node['var'] = $newValue; + } + + foreach ($node as $key => $value) { + if (is_array($value)) { + $node[$key] = $this->retypeSensitiveFields($value, $newValue); + } + } + + return $node; + } + + /** + * 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; + } + + /** + * 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) { + $rows = $dba->fetchAll( + $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', $keyName) + ); + $uuidsByDepth = [array_map(fn($row) => $row->uuid, $rows)]; + + // 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))) { + $childRows = $dba->fetchAll( + $dba->select() + ->from('director_property', ['uuid']) + ->where('parent_uuid IN (?)', end($uuidsByDepth)) + ); + $uuidsByDepth[] = array_map(fn($row) => $row->uuid, $childRows); + } + + foreach (array_reverse($uuidsByDepth) as $uuids) { + if (! empty($uuids)) { + $dba->delete('director_property', $dba->quoteInto('uuid IN (?)', $uuids)); + } + } + } + } + + parent::tearDown(); + } } diff --git a/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php b/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php index e256862f6..b3c7219af 100644 --- a/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php +++ b/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php @@ -24,4 +24,71 @@ public function testGetValueReturnsTheEnteredValue(): void $this->assertSame('s3cr3t-value', $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('new-value'); + + $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('freshly-typed-value'); + + $html = (string) $element; + + $this->assertStringContainsString('value="freshly-typed-value"', $html); + } + + public function testRenderedValueAttributeIsAbsentWhenExplicitlyEmptied(): void + { + $element = new SensitiveElement('api_token'); + $element->setValue(''); + + $html = (string) $element; + + $this->assertStringNotContainsString(SensitiveElement::DUMMYPASSWORD, $html); + } } From f2fcca20a73b44991f7c5263370a69f150ad2651 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 08:32:40 +0200 Subject: [PATCH 115/221] tests: resolve static analysis error in DictionaryItemTest due to require_once --- .../Form/DictionaryElements/DictionaryItemTest.php | 7 +------ .../Lib/TestableDictionaryItem.php | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) rename test/php/library/Director/Form/{DictionaryElements => }/Lib/TestableDictionaryItem.php (95%) diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index 7e27ef33f..a46609256 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -5,18 +5,13 @@ namespace Tests\Icinga\Module\Director\Form\DictionaryElements; -// icingaweb2's test autoloader only knows about Tests\Icinga\Module\Director\Lib -// (test/php/Lib). It can't find this class, since it sits one folder deeper, next to -// this test. So we load it by hand instead. -require_once __DIR__ . '/Lib/TestableDictionaryItem.php'; - 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\DictionaryElements\Lib\TestableDictionaryItem; +use Tests\Icinga\Module\Director\Form\Lib\TestableDictionaryItem; class DictionaryItemTest extends BaseTestCase { diff --git a/test/php/library/Director/Form/DictionaryElements/Lib/TestableDictionaryItem.php b/test/php/library/Director/Form/Lib/TestableDictionaryItem.php similarity index 95% rename from test/php/library/Director/Form/DictionaryElements/Lib/TestableDictionaryItem.php rename to test/php/library/Director/Form/Lib/TestableDictionaryItem.php index 075b8f4d3..78270a934 100644 --- a/test/php/library/Director/Form/DictionaryElements/Lib/TestableDictionaryItem.php +++ b/test/php/library/Director/Form/Lib/TestableDictionaryItem.php @@ -3,7 +3,7 @@ // SPDX-FileCopyrightText: 2026 Icinga GmbH // SPDX-License-Identifier: GPL-3.0-or-later -namespace Tests\Icinga\Module\Director\Form\DictionaryElements\Lib; +namespace Tests\Icinga\Module\Director\Form\Lib; use Icinga\Module\Director\Forms\DictionaryElements\DictionaryItem; use Icinga\Module\Director\Web\Form\Element\SensitiveElement; From 0a070ad14647f76a3bd322d853418b2d0564102d Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 10:06:41 +0200 Subject: [PATCH 116/221] DictionaryItem: Resolve saving null to fixed-array items for string items --- application/forms/DictionaryElements/DictionaryItem.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index 403b59698..fc1592499 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -519,15 +519,16 @@ public function getItem(): array $values['value'] = $this->getElement('var-search')->getValue(); } else { $type = $this->getElement('type')->getValue(); + $parentType = $this->getElement('parent_type')->getValue(); - if (! empty($this->getElement('inherited')->getValue())) { + if (empty($parentType) && ! empty($this->getElement('inherited')->getValue())) { $values['value'] = $itemValue->getValue(); } else { $defaultValue = null; // Use the default value for fixed-array items only if the fixed array does not have an // inherited value. - if ($this->getElement('parent_type')->getValue() === 'fixed-array') { + if ($parentType === 'fixed-array') { match ($type) { 'string', 'sensitive' => $defaultValue = '', 'number' => $defaultValue = 0, From 42dd8075f5fb8a1eb64dd367da14801fc86e8459 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 10:13:30 +0200 Subject: [PATCH 117/221] DictionaryItemTest: remove tests trying to connect to a non-existent resource --- .../DictionaryElements/DictionaryItemTest.php | 356 +----------------- 1 file changed, 6 insertions(+), 350 deletions(-) diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index a46609256..30851536a 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -5,21 +5,17 @@ namespace Tests\Icinga\Module\Director\Form\DictionaryElements; -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 PHPUnit\Framework\TestCase; use Tests\Icinga\Module\Director\Form\Lib\TestableDictionaryItem; -class DictionaryItemTest extends BaseTestCase +/** + * Currently only sensitive types are tested, the tests need to be extended + * to cover all types. + */ +class DictionaryItemTest extends TestCase { - private const PREFIX = '___TEST___'; - - /** @var string[] key_names of root properties created in tests (for tearDown) */ - private array $createdKeyNames = []; - public function testPrepareScrubsInheritedSecretButKeepsItsPresenceForSensitiveType(): void { $property = [ @@ -187,344 +183,4 @@ public function testGetItemClearsExistingSensitiveValueWhenInheritedAndExplicitl $this->assertSame('', $item->getItem()['value']); } - - 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']); - } - - public function testSensitiveFieldDirectlyInsideDynamicDictionaryEntryPreservesValueWhenLeftUnchanged(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(); - - $result = $dictionaryItem->getItem(); - - $this->assertSame('s3cr3t-disk-token', $result['value']['/var/log']['api_token']); - } - - public function testSensitiveFieldNestedAsGrandchildOfDynamicDictionaryPreservesValueWhenLeftUnchanged(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(); - - $result = $dictionaryItem->getItem(); - - $this->assertSame('s3cr3t-nested-token', $result['value']['/var/log']['disk_users']['token']); - } - - public function testSensitiveFieldsInsideDynamicDictionaryEntryAreClearedWhenExplicitlyEmptied(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(clearInsteadOfUnchanged: true); - - $result = $dictionaryItem->getItem(); - - $this->assertSame('', $result['value']['/var/log']['api_token']); - $this->assertSame('', $result['value']['/var/log']['disk_users']['token']); - } - - public function testSensitiveFieldsInsideDynamicDictionaryEntryKeepANewlyTypedValue(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(retypedValue: 'freshly-typed-value'); - - $result = $dictionaryItem->getItem(); - - $this->assertSame('freshly-typed-value', $result['value']['/var/log']['api_token']); - $this->assertSame('freshly-typed-value', $result['value']['/var/log']['disk_users']['token']); - } - - /** - * Build a dynamic-dictionary DictionaryItem ("disk_thresholds") backed by real - * director_property rows. It has one sensitive field directly on each entry - * ("api_token") and one nested one level deeper ("disk_users.token"). Loads and - * populates it the same way the controller does, so the sensitive fields are - * submitted as either the DUMMYPASSWORD placeholder (untouched), an empty string - * (cleared), or a new value, never the real secret. - */ - private function buildDiskThresholdsDictionaryItem( - bool $clearInsteadOfUnchanged = false, - ?string $retypedValue = null - ): DictionaryItem { - $db = $this->getDb(); - $parentUuidBytes = Uuid::uuid4()->getBytes(); - $keyName = self::PREFIX . 'disk_thresholds'; - $this->createdKeyNames[] = $keyName; - - DirectorProperty::create([ - 'uuid' => $parentUuidBytes, - 'key_name' => $keyName, - 'value_type' => 'dynamic-dictionary', - 'label' => 'Disk Thresholds', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'threshold', - 'parent_uuid' => $parentUuidBytes, - 'value_type' => 'number', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'api_token', - 'parent_uuid' => $parentUuidBytes, - 'value_type' => 'sensitive', - ], $db)->store(); - - $diskUsersUuidBytes = Uuid::uuid4()->getBytes(); - DirectorProperty::create([ - 'uuid' => $diskUsersUuidBytes, - 'key_name' => 'disk_users', - 'parent_uuid' => $parentUuidBytes, - 'value_type' => 'fixed-dictionary', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'team', - 'parent_uuid' => $diskUsersUuidBytes, - 'value_type' => 'string', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'token', - 'parent_uuid' => $diskUsersUuidBytes, - 'value_type' => 'sensitive', - ], $db)->store(); - - $property = [ - 'uuid' => $parentUuidBytes, - 'key_name' => $keyName, - 'value_type' => 'dynamic-dictionary', - 'label' => 'Disk Thresholds', - 'value' => [ - '/var/log' => [ - 'threshold' => 5, - 'api_token' => 's3cr3t-disk-token', - 'disk_users' => [ - 'team' => 'ops', - 'token' => 's3cr3t-nested-token', - ], - ], - ], - ]; - - $preparedValues = DictionaryItem::prepare($property); - - $dictionaryItem = new DictionaryItem('0', $property); - $dictionaryItem->populate($preparedValues); - $submittedValues = match (true) { - $retypedValue !== null => $this->retypeSensitiveFields($preparedValues, $retypedValue), - $clearInsteadOfUnchanged => $this->clearSensitiveFields($preparedValues), - default => $this->markSensitiveFieldsUnchanged($preparedValues), - }; - $dictionaryItem->populate($submittedValues); - $dictionaryItem->ensureAssembled(); - - return $dictionaryItem; - } - - /** - * Replace every sensitive field's value with the DUMMYPASSWORD placeholder, like a - * browser resubmitting an untouched field. - */ - private function markSensitiveFieldsUnchanged(array $node): array - { - if (($node['type'] ?? null) === 'sensitive') { - $node['var'] = SensitiveElement::DUMMYPASSWORD; - } - - foreach ($node as $key => $value) { - if (is_array($value)) { - $node[$key] = $this->markSensitiveFieldsUnchanged($value); - } - } - - return $node; - } - - /** - * Replace every sensitive field's value with an empty string, like a browser - * submitting a field the user cleared. - */ - private function clearSensitiveFields(array $node): array - { - if (($node['type'] ?? null) === 'sensitive') { - $node['var'] = ''; - } - - foreach ($node as $key => $value) { - if (is_array($value)) { - $node[$key] = $this->clearSensitiveFields($value); - } - } - - return $node; - } - - /** - * Replace every sensitive field's value with a new value, like a browser - * submitting a field the user just changed. - */ - private function retypeSensitiveFields(array $node, string $newValue): array - { - if (($node['type'] ?? null) === 'sensitive') { - $node['var'] = $newValue; - } - - foreach ($node as $key => $value) { - if (is_array($value)) { - $node[$key] = $this->retypeSensitiveFields($value, $newValue); - } - } - - return $node; - } - - /** - * 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; - } - - /** - * 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) { - $rows = $dba->fetchAll( - $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', $keyName) - ); - $uuidsByDepth = [array_map(fn($row) => $row->uuid, $rows)]; - - // 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))) { - $childRows = $dba->fetchAll( - $dba->select() - ->from('director_property', ['uuid']) - ->where('parent_uuid IN (?)', end($uuidsByDepth)) - ); - $uuidsByDepth[] = array_map(fn($row) => $row->uuid, $childRows); - } - - foreach (array_reverse($uuidsByDepth) as $uuids) { - if (! empty($uuids)) { - $dba->delete('director_property', $dba->quoteInto('uuid IN (?)', $uuids)); - } - } - } - } - - parent::tearDown(); - } } From 0c42a5fd1cad8d52a9c0acdfeec961925b1d018e Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 10:58:23 +0200 Subject: [PATCH 118/221] DictionaryItem: Extract db adapter resolution into getDb() --- .../forms/DictionaryElements/DictionaryItem.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index fc1592499..bedcde13f 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -18,6 +18,7 @@ use PDO; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; +use Zend_Db_Adapter_Abstract; /** * @phpstan-type DictionaryItemDataType array{ @@ -42,9 +43,14 @@ public function __construct(string $name, array $items, $attributes = null) 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 = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); + $db = static::getDb(); $query = $db->select() ->from( ['dp' => 'director_property'], @@ -66,7 +72,7 @@ private static function fetchItemType(UuidInterface $uuid): ?string */ private static function fetchDataListEntries(UuidInterface $uuid): array { - $db = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); + $db = static::getDb(); $query = $db->select() ->from( ['dle' => 'director_datalist_entry'], @@ -403,7 +409,7 @@ private static function fetchChildrenItems( string $parentType, array $values = [] ): array { - $db = Db::fromResourceName(Config::module('director')->get('db', 'resource'))->getDbAdapter(); + $db = static::getDb(); $query = $db->select() ->from( From 6ef003007ddd1b43d2c4cfa79ba5d1c80bb7c8d5 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 11:03:21 +0200 Subject: [PATCH 119/221] DictionaryItemTest: Restore db-backed coverage and add mergeChildValues tests --- .../DictionaryElements/DictionaryItemTest.php | 451 +++++++++++++++++- 1 file changed, 449 insertions(+), 2 deletions(-) diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index 30851536a..56e86d13a 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -5,17 +5,40 @@ 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 PHPUnit\Framework\TestCase; +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 TestCase +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 = [ @@ -122,6 +145,22 @@ public function testGetItemDefaultsSensitiveValueToEmptyStringWhenInheritedAndLe $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 testGetItemPreservesExistingSensitiveValueWhenLeftUnchanged(): void { // Left untouched means the browser sends back the DUMMYPASSWORD placeholder, @@ -183,4 +222,412 @@ public function testGetItemClearsExistingSensitiveValueWhenInheritedAndExplicitl $this->assertSame('', $item->getItem()['value']); } + + 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']); + } + + public function testSensitiveFieldDirectlyInsideDynamicDictionaryEntryPreservesValueWhenLeftUnchanged(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(); + + $result = $dictionaryItem->getItem(); + + $this->assertSame('s3cr3t-disk-token', $result['value']['/var/log']['api_token']); + } + + public function testSensitiveFieldNestedAsGrandchildOfDynamicDictionaryPreservesValueWhenLeftUnchanged(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(); + + $result = $dictionaryItem->getItem(); + + $this->assertSame('s3cr3t-nested-token', $result['value']['/var/log']['disk_users']['token']); + } + + public function testSensitiveFieldsInsideDynamicDictionaryEntryAreClearedWhenExplicitlyEmptied(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(clearInsteadOfUnchanged: true); + + $result = $dictionaryItem->getItem(); + + $this->assertSame('', $result['value']['/var/log']['api_token']); + $this->assertSame('', $result['value']['/var/log']['disk_users']['token']); + } + + public function testSensitiveFieldsInsideDynamicDictionaryEntryKeepANewlyTypedValue(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(retypedValue: 'freshly-typed-value'); + + $result = $dictionaryItem->getItem(); + + $this->assertSame('freshly-typed-value', $result['value']['/var/log']['api_token']); + $this->assertSame('freshly-typed-value', $result['value']['/var/log']['disk_users']['token']); + } + + /** + * Build a dynamic-dictionary DictionaryItem ("disk_thresholds") backed by real + * director_property rows. It has one sensitive field directly on each entry + * ("api_token") and one nested one level deeper ("disk_users.token"). Loads and + * populates it the same way the controller does, so the sensitive fields are + * submitted as either the DUMMYPASSWORD placeholder (untouched), an empty string + * (cleared), or a new value, never the real secret. + */ + private function buildDiskThresholdsDictionaryItem( + bool $clearInsteadOfUnchanged = false, + ?string $retypedValue = null + ): DictionaryItem { + $db = $this->getDb(); + $parentUuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'disk_thresholds'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'dynamic-dictionary', + 'label' => 'Disk Thresholds', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'threshold', + 'parent_uuid' => $parentUuidBytes, + 'value_type' => 'number', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'api_token', + 'parent_uuid' => $parentUuidBytes, + 'value_type' => 'sensitive', + ], $db)->store(); + + $diskUsersUuidBytes = Uuid::uuid4()->getBytes(); + DirectorProperty::create([ + 'uuid' => $diskUsersUuidBytes, + 'key_name' => 'disk_users', + 'parent_uuid' => $parentUuidBytes, + 'value_type' => 'fixed-dictionary', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'team', + 'parent_uuid' => $diskUsersUuidBytes, + 'value_type' => 'string', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'token', + 'parent_uuid' => $diskUsersUuidBytes, + 'value_type' => 'sensitive', + ], $db)->store(); + + $property = [ + 'uuid' => $parentUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'dynamic-dictionary', + 'label' => 'Disk Thresholds', + 'value' => [ + '/var/log' => [ + 'threshold' => 5, + 'api_token' => 's3cr3t-disk-token', + 'disk_users' => [ + 'team' => 'ops', + 'token' => 's3cr3t-nested-token', + ], + ], + ], + ]; + + $preparedValues = DictionaryItem::prepare($property); + + $dictionaryItem = new DictionaryItem('0', $property); + $dictionaryItem->populate($preparedValues); + $submittedValues = match (true) { + $retypedValue !== null => $this->retypeSensitiveFields($preparedValues, $retypedValue), + $clearInsteadOfUnchanged => $this->clearSensitiveFields($preparedValues), + default => $this->markSensitiveFieldsUnchanged($preparedValues), + }; + $dictionaryItem->populate($submittedValues); + $dictionaryItem->ensureAssembled(); + + return $dictionaryItem; + } + + /** + * Replace every sensitive field's value with the DUMMYPASSWORD placeholder, like a + * browser resubmitting an untouched field. + */ + private function markSensitiveFieldsUnchanged(array $node): array + { + if (($node['type'] ?? null) === 'sensitive') { + $node['var'] = SensitiveElement::DUMMYPASSWORD; + } + + foreach ($node as $key => $value) { + if (is_array($value)) { + $node[$key] = $this->markSensitiveFieldsUnchanged($value); + } + } + + return $node; + } + + /** + * Replace every sensitive field's value with an empty string, like a browser + * submitting a field the user cleared. + */ + private function clearSensitiveFields(array $node): array + { + if (($node['type'] ?? null) === 'sensitive') { + $node['var'] = ''; + } + + foreach ($node as $key => $value) { + if (is_array($value)) { + $node[$key] = $this->clearSensitiveFields($value); + } + } + + return $node; + } + + /** + * Replace every sensitive field's value with a new value, like a browser + * submitting a field the user just changed. + */ + private function retypeSensitiveFields(array $node, string $newValue): array + { + if (($node['type'] ?? null) === 'sensitive') { + $node['var'] = $newValue; + } + + foreach ($node as $key => $value) { + if (is_array($value)) { + $node[$key] = $this->retypeSensitiveFields($value, $newValue); + } + } + + return $node; + } + + /** + * 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; + } + + /** + * 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(); + } } From fe4a45b546a5b66efd8e67dd566892d946e3ac38 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 11:59:34 +0200 Subject: [PATCH 120/221] MigrateCommandTest: Remove redundant tests --- .../Director/Objects/MigrateCommandTest.php | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index d6bc15c69..950076f37 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -106,21 +106,6 @@ public function testDryRunPrintsWhatWouldMigrateWithoutWriting(): void } } - public function testDryRunWithDeleteDoesNotReportDatafieldsAsDeleted(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $this->createAllFixtures($db); - - $cmd = new TestableMigrateCommand($db, ['--dry-run', '--delete', '--verbose']); - $output = $cmd->runDatafields(); - - $this->assertStringContainsString("have been migrated and deleted:\nSummary:", $output); - } - public function testLiveMigrationCreatesDirectorPropertyRows(): void { if ($this->skipForMissingDb()) { From 1daa5d3cecbf945f5134b6c4e1c183870a06c694 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 13:53:11 +0200 Subject: [PATCH 121/221] HostsCommand: Fix overwriting of $type variable --- application/clicommands/HostsCommand.php | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/application/clicommands/HostsCommand.php b/application/clicommands/HostsCommand.php index a52a4f606..d5c2325f7 100644 --- a/application/clicommands/HostsCommand.php +++ b/application/clicommands/HostsCommand.php @@ -52,7 +52,7 @@ private function getObjectCustomVariables(IcingaObject $object): array return []; } - $type = $object->getShortTableName(); + $objectType = $object->getShortTableName(); $parents = $object->listAncestorIds(); @@ -64,7 +64,17 @@ private function getObjectCustomVariables(IcingaObject $object): array } $uuids[] = $object->get('uuid'); - $types = ['string', 'number', 'bool', 'fixed-array', 'dynamic-array', 'fixed-dictionary', 'dynamic-dictionary']; + $types = [ + 'string', + 'sensitive', + 'number', + 'bool', + 'fixed-array', + 'dynamic-array', + 'fixed-dictionary', + 'dynamic-dictionary' + ]; + if ($db->isPgsql()) { $cases = []; foreach ($types as $i => $type) { @@ -83,15 +93,15 @@ private function getObjectCustomVariables(IcingaObject $object): array [ 'key_name' => 'dp.key_name', 'uuid' => 'dp.uuid', - $type . '_uuid' => 'iop.' . $type . '_uuid', + $objectType . '_uuid' => 'iop.' . $objectType . '_uuid', 'value_type' => 'dp.value_type', 'label' => 'dp.label', 'children' => 'COUNT(cdp.uuid)' ] ) - ->join(['iop' => "icinga_$type" . '_property'], 'dp.uuid = iop.property_uuid', []) + ->join(['iop' => "icinga_$objectType" . '_property'], 'dp.uuid = iop.property_uuid', []) ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) - ->where('iop.' . $type . '_uuid IN (?)', $uuids) + ->where('iop.' . $objectType . '_uuid IN (?)', $uuids) ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) ->order($valueTypeOrder) ->order('children') From 4948a0975200d72cc5ee663210a6256c1db6075c Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 13:55:58 +0200 Subject: [PATCH 122/221] CustomVarRenderer: Cleanup getObjectCustomProperties() method - fetch object uuid through IcingaObject::get() method - remove unused $isOverrideVars parameter --- library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index 075812679..5bf821d43 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -390,9 +390,9 @@ private function valueTypeOrderExpr(Db $db, array $types): string * * @return array */ - protected function getObjectCustomProperties(IcingaObject $object, bool $isOverrideVars = false): array + protected function getObjectCustomProperties(IcingaObject $object): array { - if ($object->uuid === null) { + if ($object->get('uuid') === null) { return []; } From 2efa1577c0b842b8077e50e150a7d741ac54049d Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 16:18:35 +0200 Subject: [PATCH 123/221] CustomVarRenderer: Fix custom var renderer for complex customvar structures CustomVarRenderer has been fixed for fixed-array, dynamic/fixed-dictionary value_type, which may have crashed in Postgres --- library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index 5bf821d43..285342de0 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -294,7 +294,7 @@ public function prefetchForObject(Model $object): bool 'label' => 'dpc.label', 'value_type' => 'dpc.value_type', 'uuid' => 'dpc.uuid' - ])->where('dpp.value_type', '*-dictionary'); + ])->where('dpp.value_type', ['fixed-dictionary', 'dynamic-dictionary']); foreach ($dictionaryItems as $dictionaryItem) { $propertyName = $dictionaryItem->key_name; @@ -328,7 +328,7 @@ public function prefetchForObject(Model $object): bool 'parent_name' => 'dpp.key_name', 'key_name' => 'dpc.key_name', ]) - ->where('dpp.value_type', '*-array') + ->where('dpp.value_type', 'fixed-array') ->where('dpc.value_type', 'sensitive'); foreach ($sensitiveArrayItems as $sensitiveArrayItem) { From a9485859f394914285d7632e688591ce97f3e7c9 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 16:22:44 +0200 Subject: [PATCH 124/221] ObjectController: Remove trailing space after 'key_name' --- library/Director/Web/Controller/ObjectController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index 589148a47..d86c12b7a 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -892,7 +892,7 @@ protected function fetchVar(string $varName) ['dp' => 'director_property'], ['*'] ) - ->where('parent_uuid IS NULL AND key_name ', $varName); + ->where('parent_uuid IS NULL AND key_name', $varName); return $db->getDbAdapter()->fetchRow($query); } From 448101d982eedbc35ad0255ef3ddb3fab3e6a8a1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 16:32:38 +0200 Subject: [PATCH 125/221] HostsCommand: Fix getObjectCustomVariables() for Postgres --- application/clicommands/HostsCommand.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/application/clicommands/HostsCommand.php b/application/clicommands/HostsCommand.php index d5c2325f7..a4fb9ee22 100644 --- a/application/clicommands/HostsCommand.php +++ b/application/clicommands/HostsCommand.php @@ -3,6 +3,7 @@ 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; @@ -60,10 +61,10 @@ private function getObjectCustomVariables(IcingaObject $object): array $db = $object->getConnection(); foreach ($parents as $parent) { - $uuids[] = IcingaHost::loadWithAutoIncId($parent, $db)->get('uuid'); + $uuids[] = DbUtil::binaryResult(IcingaHost::loadWithAutoIncId($parent, $db)->get('uuid')); } - $uuids[] = $object->get('uuid'); + $uuids[] = DbUtil::binaryResult($object->get('uuid')); $types = [ 'string', 'sensitive', @@ -101,7 +102,10 @@ private function getObjectCustomVariables(IcingaObject $object): array ) ->join(['iop' => "icinga_$objectType" . '_property'], 'dp.uuid = iop.property_uuid', []) ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) - ->where('iop.' . $objectType . '_uuid IN (?)', $uuids) + ->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') @@ -109,7 +113,7 @@ private function getObjectCustomVariables(IcingaObject $object): array $result = []; foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { - $result[$row['key_name']] = $row; + $result[$row['key_name']] = DbUtil::normalizeRow($row); } return $result; From d6b5fa75bb8accf5e1e68cba6e65b86d8b9ec2df Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 14 Jul 2026 16:37:50 +0200 Subject: [PATCH 126/221] ObjectController: Fix uuid in $propertyData for Postgres in sendNewVarMultipartUpdate() --- library/Director/Web/Controller/ObjectController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index d86c12b7a..5f5beef93 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -545,7 +545,7 @@ private function sendNewVarMultipartUpdate( $propertyData = [ 'key_name' => $row['key_name'], - 'uuid' => $row['uuid'], + 'uuid' => DbUtil::binaryResult($row['uuid']), 'value_type' => $row['value_type'], 'label' => $row['label'], 'allow_removal' => true, From 251b37a7cfdf79ec3c61a7a1d532eda190fad6df Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 09:37:57 +0200 Subject: [PATCH 127/221] DirectorProperty: Move NON_NESTABLE_TYPES constant above method and property definitions --- library/Director/Objects/DirectorProperty.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index 3973ed875..77a435fbc 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -13,6 +13,9 @@ class DirectorProperty extends DbObject { + /** Value types that may never be used for a nested (non-top-level) property */ + private const NON_NESTABLE_TYPES = ['dynamic-dictionary']; + protected $table = 'director_property'; protected $keyName = 'key_name'; @@ -406,9 +409,6 @@ public static function import(stdClass $plain, Db $db): static return $property; } - /** Value types that may never be used for a nested (non-top-level) property */ - private const NON_NESTABLE_TYPES = ['dynamic-dictionary']; - /** * @throws InvalidArgumentException if a nested property is being stored with a value_type * that may only be used at the top level From 9fad428bf8b13e098ab5b1bb04bd7a3b1db8f346 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 10:32:34 +0200 Subject: [PATCH 128/221] MigrateCommand: Add summary action to avoid implicit datafields migration icingacli director migrate had exactly one action (datafields), so Icinga's CLI Loader silently auto-resolved a bare `icingacli director migrate` to it, running the migration without the user ever asking for it. Adding a second real action, summary, removes that ambiguity: the framework now shows the available actions on a bare invocation instead of guessing. --- application/clicommands/MigrateCommand.php | 46 ++++++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index 19bd54d31..fe6dc1eac 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -30,15 +30,53 @@ class MigrateCommand extends Command private $migratedDataFields = []; /** - * Run any pending migrations + * Show what would be migrated, without making any changes + * + * USAGE + * + * icingacli director migrate summary + */ + public function summaryAction() + { + $customPropertiesToMigrate = $this->prepareCustomProperties(); + $this->checkMigrateableDatafieldTypes(); + $this->checkDatafieldsWithCategory(); + $this->checkUnmigrateableDatafieldTypes(); + $this->checkDatafieldsWithDuplicateNames(); + printf( + "Number of datafields that can not be migrated as the custom properties with the same name already" + . " exists: %d\n", + count($this->existingCustomProperties) + ); + + $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 datafields that could be migrated: %d\n", $totalMigrated); + printf("Total datafields skipped: %d\n", $totalSkipped); + } + + /** + * Run datafield migration + * + * USAGE * * icingacli director migrate datafields --dry-run --delete --verbose * - * - --dry-run: Preview what would be migrated without writing to the database + * 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) + * --delete Remove original datafield records and their bindings after migration (skipped with --dry-run) * - * - --verbose: Show detailed migration results + * --verbose Show detailed migration results */ public function datafieldsAction() { From 9f90e59c627196224a75133d05ff46840ef2a5fe Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 10:33:12 +0200 Subject: [PATCH 129/221] MigrateCommand: Clean up confusing datafield migration summary The migrateable/unmigrateable type breakdown relied on a hardcoded blocklist (SqlQuery, DirectorObject, Dictionary) that had drifted out of sync with the actual allow-list used to build custom properties. Datatypes outside both lists, like Time, were counted as migrateable in the report yet silently dropped later, so the printed totals never added up. Both classifications now derive from a single SUPPORTED_DATATYPES list, and the report explicitly reconciles the datatype-supported count against datafields skipped for already having a matching custom property, so the final migrated/skipped totals are no longer a mystery. --- application/clicommands/MigrateCommand.php | 86 ++++++++++++------- .../Director/Objects/MigrateCommandTest.php | 2 +- 2 files changed, 56 insertions(+), 32 deletions(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index fe6dc1eac..cb85e7cdc 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -4,7 +4,6 @@ use Icinga\Data\Db\DbQuery; use Icinga\Data\Filter\Filter; -use Icinga\Data\Filter\FilterAnd; use Icinga\Data\Filter\FilterMatch; use Icinga\Module\Director\Cli\Command; use Icinga\Module\Director\Db\DbSelectParenthesis; @@ -24,6 +23,12 @@ */ class MigrateCommand extends Command { + /** + * Datatype suffixes (after 'Icinga\Module\Director\DataType\DataType') that are supported + * for migration. + */ + private const SUPPORTED_DATATYPES = ['Array', 'Boolean', 'Number', 'String', 'Datalist']; + private $existingCustomProperties = []; /** @var array Migrated Datafields ['id' => 'datafield_name'] */ @@ -39,15 +44,7 @@ class MigrateCommand extends Command public function summaryAction() { $customPropertiesToMigrate = $this->prepareCustomProperties(); - $this->checkMigrateableDatafieldTypes(); - $this->checkDatafieldsWithCategory(); - $this->checkUnmigrateableDatafieldTypes(); - $this->checkDatafieldsWithDuplicateNames(); - printf( - "Number of datafields that can not be migrated as the custom properties with the same name already" - . " exists: %d\n", - count($this->existingCustomProperties) - ); + $this->printMigrationDetails($customPropertiesToMigrate); $totalMigrated = 0; foreach ($customPropertiesToMigrate as $customProperty) { @@ -59,8 +56,8 @@ public function summaryAction() $totalSkipped = count(DirectorDatafield::loadAll($this->db())) - $totalMigrated; echo "Summary:\n"; - printf("Total datafields that could be migrated: %d\n", $totalMigrated); - printf("Total datafields skipped: %d\n", $totalSkipped); + printf("Total number of datafields that will be migrated: %d\n", $totalMigrated); + printf("Total number of datafields that will be skipped: %d\n", $totalSkipped); } /** @@ -86,15 +83,7 @@ public function datafieldsAction() $delete = $this->params->shift('delete') ?? false; // Dry run summary if ($dryRun) { - $this->checkMigrateableDatafieldTypes(); - $this->checkDatafieldsWithCategory(); - $this->checkUnmigrateableDatafieldTypes(); - $this->checkDatafieldsWithDuplicateNames(); - printf( - "Number of datafields that can not be migrated as the custom properties with the same name already" - . " exists: %d\n", - count($this->existingCustomProperties) - ); + $this->printMigrationDetails($customPropertiesToMigrate); } echo "Migrating Data fields\n"; @@ -157,8 +146,33 @@ public function datafieldsAction() } echo "Summary:\n"; - printf("Total datafields migrated: %d\n", $totalMigrated); - printf("Total datafields skipped: %d\n", $totalSkipped); + 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) + ); } /** @@ -316,14 +330,20 @@ private function migrateDatafields(array $customProperties, bool $dryRun): void } /** - * Check what datafield types can be migrated + * 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 can be migrated:\n"); + 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"); @@ -341,7 +361,7 @@ private function checkMigrateableDatafieldTypes(): void $total += $row->count_q; } - printf("Total datafields that can be migrated: %d\n\n", $total); + printf("Total datafields with a supported datatype: %d\n\n", $total); } /** @@ -449,17 +469,21 @@ private function getDataFieldQuery(): DbQuery /** * 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(); - $query->addFilter(FilterAnd::matchAny( - FilterMatch::where('datatype', '*SqlQuery'), - FilterMatch::where('datatype', '*DirectorObject'), - FilterMatch::where('datatype', '*Dictionary') - )); + $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(); diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index 950076f37..c016a4a9e 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -408,7 +408,7 @@ public function testTotalMigratedCountExcludesUnsupportedTypes(): void // fixture set but has an unsupported type and must not be counted as migrated. $expectedMigrated = count(self::MIGRATABLE) + 1; $this->assertStringContainsString( - "Total datafields migrated: $expectedMigrated\n", + "Total number of datafields migrated: $expectedMigrated\n", $output, 'the migrated count must not include datafields with an unsupported type that were skipped' ); From 5bc91b76122d8a318323bc731d7a9b27eec77d5c Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 10:59:10 +0200 Subject: [PATCH 130/221] MigrateCommandTest: Add VAR_ENV_CHOICES_DEFAULT_BEHAVIOR to MIGRATABLE array --- test/php/library/Director/Objects/MigrateCommandTest.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index c016a4a9e..4190c60f8 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -58,6 +58,7 @@ class MigrateCommandTest extends BaseTestCase self::VAR_ENV_CHOICES, self::VAR_ENV_SUGGEST, self::VAR_HIDDEN, + self::VAR_ENV_CHOICES_DEFAULT_BEHAVIOR, ]; private const ALL_TEST_VARS = [ @@ -402,11 +403,7 @@ public function testTotalMigratedCountExcludesUnsupportedTypes(): void $cmd = new TestableMigrateCommand($db); $output = $cmd->runDatafields(); - // MIGRATABLE (6, including the hidden-string field that now migrates as 'sensitive') - // + env_choices_default_behavior (added for the datalist default-behavior fix) = 7 - // datafields actually get a director_property row. VAR_TIME_FIELD is present in the - // fixture set but has an unsupported type and must not be counted as migrated. - $expectedMigrated = count(self::MIGRATABLE) + 1; + $expectedMigrated = count(self::MIGRATABLE); $this->assertStringContainsString( "Total number of datafields migrated: $expectedMigrated\n", $output, From 7b5caaa5b95de18f48a04f76f0a3c4bcc07a41d1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 11:23:18 +0200 Subject: [PATCH 131/221] DirectorPropertyTest: Remove mentions of regression tests in PHPDoc blocks --- .../library/Director/Objects/DirectorPropertyTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 97c98a3d3..afdfee71a 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -235,10 +235,10 @@ public function testDatalistImportRestoresDatalistLink(): void } /** - * Regression test: 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). + * 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 { @@ -288,7 +288,7 @@ public function testDatalistChildOfDynamicDictionaryIsPersistedWhenListDoesNotEx } /** - * Same regression as above, one level deeper: the not-yet-existing datalist is referenced + * Same as above, one level deeper: the not-yet-existing datalist is referenced * by a GRANDCHILD (dynamic-dictionary -> fixed-dictionary -> datalist-strict). */ public function testDatalistGrandchildOfDynamicDictionaryIsPersistedWhenListDoesNotExistYet(): void From 30e3683fd3ece18f625ea1e2fe572a010962fb20 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 11:24:47 +0200 Subject: [PATCH 132/221] CustomVariableForm: Show 'List Name' as required --- application/forms/CustomVariableForm.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index f51c1d5bc..da00f07e7 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -245,7 +245,7 @@ protected function assemble(): void 'select', 'list', [ - 'label' => $this->translate('List name'), + 'label' => $this->translate('List name *'), 'class' => 'autosubmit', 'disabledOptions' => [''], 'value' => '', From 8095680a9e74cc7f77d215a7ac860108ec5ab4f6 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 11:55:51 +0200 Subject: [PATCH 133/221] DeleteCustomVariableForm: Move array_merge outside the loop in collectDescendantUuids() --- application/forms/DeleteCustomVariableForm.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index d19e0528f..9a5d8d641 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -325,11 +325,11 @@ private function collectDescendantUuids(string $uuid): array ); $children = array_map([DbUtil::class, 'binaryResult'], $children); - $descendants = array_merge($descendants, $children); + $descendants[] = $children; $parents = $children; } - return $descendants; + return array_merge(...$descendants); } /** From 5062b1229d4928692be7fc91084916a23379f4db Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 12:58:56 +0200 Subject: [PATCH 134/221] CustomVariablesForm: Use LogicException instead of deprecated ProgrammingError --- application/forms/CustomVariablesForm.php | 11 +++++------ .../library/Director/Form/CustomVariablesFormTest.php | 6 +++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index 275411538..0dbe1852e 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -2,7 +2,6 @@ namespace Icinga\Module\Director\Forms; -use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\Forms\DictionaryElements\Dictionary; @@ -15,12 +14,12 @@ use Icinga\Web\Session; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; -use ipl\Html\Html; use ipl\Html\HtmlElement; use ipl\Html\Text; use ipl\I18n\Translation; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; +use LogicException; use Ramsey\Uuid\Uuid; class CustomVariablesForm extends CompatForm @@ -209,12 +208,12 @@ public function isOverrideServiceVars(): bool * * @return void * - * @throws ProgrammingError + * @throws LogicException */ private function assertOverrideHostIsSet(): void { if ($this->isOverrideServiceVars() && $this->host === null) { - throw new ProgrammingError( + throw new LogicException( 'CustomVariablesForm needs setHostForService() to be called before overriding service variables' ); } @@ -331,12 +330,12 @@ function ($item) { * * @return void * - * @throws ProgrammingError + * @throws LogicException */ private function assertCanAttachNewVariable(): void { if (! $this->object->isTemplate()) { - throw new ProgrammingError( + throw new LogicException( 'Custom Variables can only be attached directly to a template, got %s', $this->object->getObjectName() ); diff --git a/test/php/library/Director/Form/CustomVariablesFormTest.php b/test/php/library/Director/Form/CustomVariablesFormTest.php index 5a4a66847..d2a7e9e29 100644 --- a/test/php/library/Director/Form/CustomVariablesFormTest.php +++ b/test/php/library/Director/Form/CustomVariablesFormTest.php @@ -5,11 +5,11 @@ namespace Tests\Icinga\Module\Director\Form; -use Icinga\Exception\ProgrammingError; 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 @@ -24,7 +24,7 @@ public function testAttachingNewPropertyToNonTemplateThrows(): void $method = new ReflectionMethod($form, 'assertCanAttachNewVariable'); $method->setAccessible(true); - $this->expectException(ProgrammingError::class); + $this->expectException(LogicException::class); $method->invoke($form); } @@ -111,7 +111,7 @@ public function testOverrideWithoutHostThrows(): void $method = new ReflectionMethod($form, 'assertOverrideHostIsSet'); $method->setAccessible(true); - $this->expectException(ProgrammingError::class); + $this->expectException(LogicException::class); $method->invoke($form); } From 9634aafbe69785ad37c4009997814489ccf40edb Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 14:56:49 +0200 Subject: [PATCH 135/221] HostController: Add submit handler to forms overriding service variables --- application/controllers/HostController.php | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php index dc0d519fe..d58317cb0 100644 --- a/application/controllers/HostController.php +++ b/application/controllers/HostController.php @@ -4,11 +4,15 @@ 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; @@ -476,6 +480,7 @@ public function appliedserviceAction() } 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())); @@ -603,6 +608,7 @@ public function inheritedserviceAction() } else { $this->controls()->prepend($deactivateForm); $form = $this->prepareCustomPropertiesForm($parent, $host); + $this->customVarFormOnSubmit($form, $host); $form->setInheritedServiceFrom($from->getObjectName()); $form->setHostForService($host); @@ -686,6 +692,7 @@ public function servicesetserviceAction() } else { $this->controls()->prepend($deactivateForm); $form = $this->prepareCustomPropertiesForm($originalService, $host); + $this->customVarFormOnSubmit($form, $host); $form->setServiceSet($setTemplate); $form->setHostForService($host); @@ -698,6 +705,32 @@ public function servicesetserviceAction() $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.') + ); + } + } + ); + } + /** * @throws \Icinga\Exception\NotFoundError */ From 487baf4fd3cfb0c8ba8ce6878ef6098cd2dcfee3 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 15:06:39 +0200 Subject: [PATCH 136/221] CustomVariablesForm: Resolve incorrect arguments passed to LogicException --- application/forms/CustomVariablesForm.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index 0dbe1852e..de204dd2d 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -335,10 +335,10 @@ function ($item) { private function assertCanAttachNewVariable(): void { if (! $this->object->isTemplate()) { - throw new LogicException( + throw new LogicException(sprintf( 'Custom Variables can only be attached directly to a template, got %s', $this->object->getObjectName() - ); + )); } } From 9350b1bc741be55c91b4f492523f21c6f0981515 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 15:32:04 +0200 Subject: [PATCH 137/221] HostController: Redirect on submitting the CustomVariablesForm --- application/controllers/HostController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php index d58317cb0..1724f5700 100644 --- a/application/controllers/HostController.php +++ b/application/controllers/HostController.php @@ -727,6 +727,8 @@ function (CustomVariablesForm $form) use ($host) { $this->translate('There is nothing to change.') ); } + + $this->redirectNow(Url::fromRequest()); } ); } From 80d5ef549c6d7f446c0d6e47e850f0c709b9b1de Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 16:47:43 +0200 Subject: [PATCH 138/221] ObjectController: Do not show inherited values for overridden service vars --- library/Director/Web/Controller/ObjectController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index 5f5beef93..d7e401e8a 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -676,7 +676,7 @@ public function prepareCustomPropertiesForm( $row['value'] = $vars[$row['key_name']]; } - if (isset($inheritedVars[$row['key_name']])) { + if (isset($inheritedVars[$row['key_name']]) && ! $isOverrideVars) { $row['inherited'] = $inheritedVars[$row['key_name']]; $row['inherited_from'] = $origins->{$row['key_name']}; } From da192e57a4c696aab0c0036fca4440678ae6d71a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 16:48:26 +0200 Subject: [PATCH 139/221] CustomVariablesForm: Correctly update overridden service variables --- application/forms/CustomVariablesForm.php | 36 ++++++++++++++++------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index de204dd2d..d7665a2a6 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -371,6 +371,12 @@ protected function onSuccess(): void $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'])) { @@ -417,12 +423,28 @@ protected function onSuccess(): void } if (self::isValueUnset($value)) { - $vars->set($key, null); + if ($isOverrideServiceVars) { + if (isset($overrideVars[$key])) { + unset($overrideVars[$key]); + $this->varsHasBeenModified = true; + } + } else { + $vars->set($key, null); + } } else { - $vars->set($key, $value); + if ($isOverrideServiceVars) { + $overrideVars[$key] = $value; + } else { + $vars->set($key, $value); + } } - if ($vars->get($key) && $vars->get($key)->getUuid() === null && isset($property['uuid'])) { + if ( + ! $isOverrideServiceVars + && $vars->get($key) + && $vars->get($key)->getUuid() === null + && isset($property['uuid']) + ) { $vars->registerVarUuid($key, $propertyUuid); } @@ -471,14 +493,8 @@ protected function onSuccess(): void if ($this->isOverrideServiceVars()) { $object = $this->host; - $overrideVars = (array) $this->host->getOverriddenServiceVars($this->object->getObjectName()); - foreach ($vars as $varName => $var) { - if ($var->hasBeenModified()) { - $overrideVars[$varName] = $var->getValue(); - } - } - $object->overrideServiceVars($this->object->getObjectName(), (object) $overrideVars); + $this->varsHasBeenModified = $object->hasBeenModified(); DirectorActivityLog::logModification($object, $this->object->getConnection()); $object->store($this->object->getConnection()); From 3d88dd5a69278e6c9e65230ecdf6d1f8dbb100ce Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 15 Jul 2026 16:58:25 +0200 Subject: [PATCH 140/221] CustomVariablesForm: Log only if there are any modifications in the variables --- application/forms/CustomVariablesForm.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index d7665a2a6..fac168514 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -443,6 +443,7 @@ protected function onSuccess(): void ! $isOverrideServiceVars && $vars->get($key) && $vars->get($key)->getUuid() === null + && $vars->hasBeenModified() && isset($property['uuid']) ) { $vars->registerVarUuid($key, $propertyUuid); @@ -495,12 +496,17 @@ protected function onSuccess(): void $object = $this->host; $object->overrideServiceVars($this->object->getObjectName(), (object) $overrideVars); $this->varsHasBeenModified = $object->hasBeenModified(); - DirectorActivityLog::logModification($object, $this->object->getConnection()); + if ($object->hasBeenModified()) { + DirectorActivityLog::logModification($object, $this->object->getConnection()); + } $object->store($this->object->getConnection()); } else { $object = $this->object; - DirectorActivityLog::logModification($object, $this->object->getConnection()); + if ($this->varsHasBeenModified) { + DirectorActivityLog::logModification($object, $this->object->getConnection()); + } + $vars->storeToDb($object); } } From a0606c40cd9d18ec23a8c45190c7c6bb1ddc447a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 09:30:57 +0200 Subject: [PATCH 141/221] CustomVariablesTest: Remove redundant tests --- .../CustomVariable/CustomVariablesTest.php | 95 ------------------- 1 file changed, 95 deletions(-) diff --git a/test/php/library/Director/CustomVariable/CustomVariablesTest.php b/test/php/library/Director/CustomVariable/CustomVariablesTest.php index 592b91331..36f5eb81e 100644 --- a/test/php/library/Director/CustomVariable/CustomVariablesTest.php +++ b/test/php/library/Director/CustomVariable/CustomVariablesTest.php @@ -6,11 +6,7 @@ namespace Tests\Icinga\Module\Director\CustomVariable; use Icinga\Module\Director\CustomVariable\CustomVariables; -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 CustomVariablesTest extends BaseTestCase { @@ -71,97 +67,6 @@ public function testVariablesToExpression() $this->assertEquals($expected, $vars->toConfigString(true)); } - public function testPrefetchCustomVarTypesPrefersExactObjectMatchOverAncestorRows(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - // Template needs at least one icinga_host_var row to satisfy the JOIN in - // CustomVariables::prefetchCustomVarTypes(). - $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_mount_thresholds', - 'value_type' => 'dynamic-dictionary', - 'label' => 'Disk Mount Thresholds', - ], $db); - $property->store(); - - $child = IcingaHost::create([ - 'object_name' => '___TEST___db-server-02', - 'object_type' => 'object', - 'address' => '10.0.1.55', - 'vars' => [ - '___TEST___disk_mount_thresholds' => (object) [ - 'root' => (object) ['mount_point' => '/', 'warn' => '20%', 'crit' => '10%'], - ], - ], - ], $db); - $child->imports = '___TEST___linux-server'; - $child->store(); - - // Assign the same property to both the template and the child, so the query in - // prefetchCustomVarTypes() returns two rows for this key: one with object_id equal - // to the ancestor template's id, one equal to the child's own id. Which row the - // database happens to return last must not decide which object_id gets cached. - $db->insert('icinga_host_property', [ - 'property_uuid' => DbUtil::quoteBinaryCompat($property->get('uuid'), $dba), - 'host_uuid' => DbUtil::quoteBinaryCompat($template->get('uuid'), $dba), - ]); - $db->insert('icinga_host_property', [ - 'property_uuid' => DbUtil::quoteBinaryCompat($property->get('uuid'), $dba), - 'host_uuid' => DbUtil::quoteBinaryCompat($child->get('uuid'), $dba), - ]); - - $loaded = IcingaHost::load('___TEST___db-server-02', $db); - $vars = $loaded->vars(); - - self::callMethod($vars, 'prefetchCustomVarTypes', [$loaded]); - - $cacheProperty = new \ReflectionProperty(CustomVariables::class, 'cachedCustomVariableTypes'); - $cacheProperty->setAccessible(true); - $cache = $cacheProperty->getValue($vars); - - $this->assertSame( - (int) $loaded->get('id'), - $cache['___TEST___disk_mount_thresholds']['object_id'], - 'the exact object being rendered must win over an ancestor row for the same key, ' - . 'regardless of the order the database returns rows in' - ); - } - - public function tearDown(): void - { - if ($this->hasDb()) { - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - // Child must be deleted before the template it imports from. - foreach (['___TEST___db-server-02', '___TEST___linux-server'] as $name) { - if (IcingaHost::exists($name, $db)) { - IcingaHost::load($name, $db)->delete(); - } - } - - $dba->delete( - 'director_property', - $dba->quoteInto('key_name = ?', '___TEST___disk_mount_thresholds') - ); - } - - parent::tearDown(); - } - protected function indentVarsList($vars) { return $this->indent . implode( From 1ee83a48a11d9fecea84e29bc5c01089e704f031 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 09:31:51 +0200 Subject: [PATCH 142/221] CustomVariables: Use table alias consistently for all columns --- library/Director/CustomVariable/CustomVariables.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php index ccc247b68..9b324b793 100644 --- a/library/Director/CustomVariable/CustomVariables.php +++ b/library/Director/CustomVariable/CustomVariables.php @@ -253,7 +253,7 @@ public static function loadForStoredObject(IcingaObject $object) 'v.format' ]; - $columns[] = 'property_uuid'; + $columns[] = 'v.property_uuid'; $query = $db->select()->from( ['v' => $object->getVarsTableName()], $columns From a3af3ebb057c66478a748a7f8a0455e6cfeef794 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 09:44:32 +0200 Subject: [PATCH 143/221] CustomvarController: Remove calling Uuid::fromString() on UuidInterface --- application/controllers/CustomvarController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/application/controllers/CustomvarController.php b/application/controllers/CustomvarController.php index 779ff6e89..696bd12af 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -311,7 +311,6 @@ public function addFieldAction(): void { $uuid = $this->uuid; $this->addTitleTab($this->translate('Create Field')); - $uuid = Uuid::fromString($uuid); $parent = $this->fetchProperty($uuid); $propertyForm = (new CustomVariableForm($this->db, null, true, $uuid)) From b0fec61a51aa948cf5e3a72f391fc9aa76a80e4b Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 10:56:44 +0200 Subject: [PATCH 144/221] module.less: Wrap overflowing apply-for key=>value table with horizontal scroll --- public/css/module.less | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/css/module.less b/public/css/module.less index b15f3dcd8..a0083c573 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -59,6 +59,8 @@ table.common-table td { background: @gray-lightest; .apply-for-header-content { padding: 0.5em; + overflow-x: auto; + max-width: 100%; } } From cee1f0c7f17b5906e798251da8c33d82fa8c1abd Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 11:24:06 +0200 Subject: [PATCH 145/221] IcingaService: Prefer legacy Data field over colliding Custom Variable name Data field names and Custom Variable names are independent namespaces. An Apply For rule created before Custom Variables existed can reference a Data field name that an unrelated Custom Variable, attached to some other host template, later happens to reuse. fetchApplyForPropertyType() matched purely by key_name with no regard for which registry it came from, so the unrelated Custom Variable's type silently took over rendering for the older rule, switching it to "key => value" iteration and injecting a spurious vars.overriddenVar line. --- library/Director/Objects/IcingaService.php | 87 +++++++++---------- .../Objects/IcingaServiceApplyForTest.php | 58 +++++++++++++ 2 files changed, 101 insertions(+), 44 deletions(-) diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index 5ca98f4fa..af5561e54 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; @@ -414,12 +415,30 @@ 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; + } + $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); @@ -427,6 +446,26 @@ protected function fetchApplyForPropertyType(string $applyFor): ?string 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; @@ -679,22 +718,15 @@ public function vars() if ($this->applyForWhiteList === null) { $query = $this->db ->select() - ->from( - ['dp' => 'director_property'], - [ - 'key_name' => 'dp.key_name', - 'uuid' => 'dp.uuid', - 'value_type' => 'dp.value_type' - ] - ) + ->from(['dp' => 'director_property'], ['key_name' => 'dp.key_name']) ->join(['parent_dp' => 'director_property'], 'dp.parent_uuid = parent_dp.uuid', []) + ->where('parent_dp.parent_uuid IS NULL') ->where("parent_dp.value_type = 'dynamic-dictionary'") ->where("parent_dp.key_name = ?", $applyFor); $result = $this->db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); - $propertyType = $this->fetchApplyForPropertyType($applyFor); - $isApplyForDictionary = $propertyType === 'dynamic-dictionary'; + $isApplyForDictionary = $this->fetchApplyForPropertyType($applyFor) === 'dynamic-dictionary'; $whiteList = ['value', 'host.*', 'value[*]', 'value[*].*']; if ($isApplyForDictionary) { $whiteList[] = 'key'; @@ -711,18 +743,7 @@ public function vars() continue; } - $variable = sprintf('value.%s', $row['key_name']); - if ($row['value_type'] === 'dynamic-dictionary') { - foreach ($this->fetchItemsForDictionary($row['uuid']) as $value) { - if (str_contains($value['key_name'], ' ')) { - continue; - } - - $whiteList[] = sprintf('%s.%s', $variable, $value['key_name']); - } - } - - $whiteList[] = $variable; + $whiteList[] = sprintf('value.%s', $row['key_name']); } $this->applyForWhiteList = $whiteList; @@ -733,28 +754,6 @@ public function vars() return $vars; } - protected function fetchItemsForDictionary(string $uuid): array - { - $query = $this->db - ->select() - ->from( - ['dp' => 'director_property'], - [ - 'key_name' => 'dp.key_name', - 'uuid' => 'dp.uuid', - 'value_type' => 'dp.value_type', - ] - ) - ->join(['parent_dp' => 'director_property'], 'dp.parent_uuid = parent_dp.uuid', []) - ->where( - 'dp.parent_uuid = ?', - Db\DbUtil::quoteBinaryCompat(Db\DbUtil::binaryResult($uuid), $this->db) - ); - - return $this->db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); - } - - /** * TODO: Duplicate code, clean this up, split it into multiple methods * @param ?Db $connection diff --git a/test/php/library/Director/Objects/IcingaServiceApplyForTest.php b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php index 247ef8173..300fd1474 100644 --- a/test/php/library/Director/Objects/IcingaServiceApplyForTest.php +++ b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php @@ -5,7 +5,9 @@ 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; @@ -99,6 +101,62 @@ public function testApplyForWithNoPropertyFallsBackToValue(): void ); } + 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()) { From 88e501b7a5240a5378705be965221cbc7cda8155 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 12:27:17 +0200 Subject: [PATCH 146/221] CustomVarRenderer: Add all queried columns to group by in getObjectCustomProperties() --- .../Director/ProvidedHook/Icingadb/CustomVarRenderer.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index 285342de0..80fbdb1ec 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -426,7 +426,14 @@ protected function getObjectCustomProperties(IcingaObject $object): array ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) ->joinLeft(['cpc' => 'director_datafield_category'], 'dp.category_id = cpc.id', []) ->where('iop.' . $type . '_uuid IN (?)', $uuids) - ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label']) + ->group([ + 'dp.uuid', + 'dp.key_name', + 'dp.value_type', + 'dp.label', + 'cpc.category_name', + 'iop.' . $type . '_uuid' + ]) ->order($this->valueTypeOrderExpr($db, [ 'string', 'number', From 8efae9dbc5d774782b1e41fb2037b99e5ddbf374 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 12:28:12 +0200 Subject: [PATCH 147/221] CustomVariableReferenceLoader:Export 'required' flag in Basket --- library/Director/Data/CustomVariableReferenceLoader.php | 1 + 1 file changed, 1 insertion(+) diff --git a/library/Director/Data/CustomVariableReferenceLoader.php b/library/Director/Data/CustomVariableReferenceLoader.php index fb5bd33cf..49c708ff5 100644 --- a/library/Director/Data/CustomVariableReferenceLoader.php +++ b/library/Director/Data/CustomVariableReferenceLoader.php @@ -37,6 +37,7 @@ public function loadFor(IcingaObject $object): array $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') From 9dfa2891d7cabef61e8d2b6d1e6a954dd39c5628 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 13:01:17 +0200 Subject: [PATCH 148/221] IcingaConfigHelper: Bound wildcard whitelist matches to safe character classes A wildcard entry previously expanded to an unbounded .* in the generated regex, so a value like `host.name) { throw "injected"` matched `host.*` and was emitted unquoted by renderStringWithVariables(). Each wildcard now expands to a narrow, context-appropriate class instead: digits only inside literal brackets (value[*] is an array index), and the same safe identifier/dot character class the base macro pattern already allows everywhere else. --- .../IcingaConfig/IcingaConfigHelper.php | 25 +++++++++++------- .../IcingaConfig/IcingaConfigHelperTest.php | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/library/Director/IcingaConfig/IcingaConfigHelper.php b/library/Director/IcingaConfig/IcingaConfigHelper.php index 03d4d4632..da439c047 100644 --- a/library/Director/IcingaConfig/IcingaConfigHelper.php +++ b/library/Director/IcingaConfig/IcingaConfigHelper.php @@ -403,15 +403,22 @@ public static function isValidMacroName(string $name, ?array $whiteList = null): } foreach ($whiteList as $pattern) { - if (str_contains($pattern, '*')) { - if ( - preg_match( - '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/', - $name - ) - ) { - return true; - } + if (! is_string($pattern) || ! str_contains($pattern, '*')) { + continue; + } + + $regexBody = preg_quote($pattern, '/'); + // A wildcard enclosed in literal brackets stands for an array index: + // digits only, e.g. value[*] must only ever match value[0], value[12], ... + $regexBody = str_replace('\[\*\]', '\[\d+\]', $regexBody); + // Any other wildcard stands for exactly one safe macro-name segment: the + // same character class the base macro pattern allows, never arbitrary + // text. This is what keeps a value like `host.name) { throw "injected"` + // from matching `host.*`. + $regexBody = str_replace('\*', '[A-Za-z_.\d]+', $regexBody); + + if (preg_match('/^' . $regexBody . '$/', $name)) { + return true; } } diff --git a/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php index 8f1249257..a2b01c88a 100644 --- a/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php +++ b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php @@ -184,4 +184,30 @@ public function testIsValidMacroNameWhitelistOverridesPatternCheck() $this->assertTrue(c::isValidMacroName('a', ['a'])); $this->assertTrue(c::isValidMacroName('value.', ['value.'])); } + + public function testIsValidMacroNameWildcardDoesNotBypassSyntaxCheck(): void + { + // A wildcard entry must only match a single safe macro-name segment (or, inside + // brackets, a numeric index). It must not let a non-macro token (e.g. an + // attempted DSL injection payload) through just because it superficially starts + // with the wildcard's literal prefix. + $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[*].*'])); + } + + public function testIsValidMacroNameWildcardStillMatchesArrayIndexAndDictionaryForms(): void + { + // These are the actual production whitelist entries built in + // IcingaService::vars() for array/dictionary apply-for rendering. A wildcard + // fix must not regress legitimate use of any of them. + $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)); + } } From c3e91056ab5711c2ab7b094b710bd1452062ff25 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 15:00:44 +0200 Subject: [PATCH 149/221] RestApi: Make custom-variable application transaction-aware and scope PUT wipes CustomVariableValueApplier no longer manages its own transaction when the caller already opened one, so IcingaObjectHandler can wrap object persistence and custom-variable application in a single transaction. Before this, a request that changed ordinary object properties and also carried an invalid custom variable could leave the object change committed even though the request failed. The base-object PUT endpoint also no longer wipes property attachments (icinga__property), only variable values. Full replacement of attachments is now scoped to the dedicated "variables" endpoint, matching its documented full-replace contract instead of silently detaching properties from every PUT. --- .../RestApi/CustomVariableValueApplier.php | 41 ++-- .../Director/RestApi/IcingaObjectHandler.php | 187 ++++++++++++------ .../CustomVariableValueApplierTest.php | 95 +++++++++ .../RestApi/IcingaObjectHandlerTest.php | 113 +++++++++++ 4 files changed, 363 insertions(+), 73 deletions(-) diff --git a/library/Director/RestApi/CustomVariableValueApplier.php b/library/Director/RestApi/CustomVariableValueApplier.php index 0a30be178..36603923b 100644 --- a/library/Director/RestApi/CustomVariableValueApplier.php +++ b/library/Director/RestApi/CustomVariableValueApplier.php @@ -50,25 +50,38 @@ public function apply( $dbAdapter = $this->db->getDbAdapter(); $type = $object->getShortTableName(); $objectVars = $object->vars(); - $wipeInDb = $method === 'PUT' && $object->get('id'); - - if ($wipeInDb) { + $wipeValuesInDb = $method === 'PUT' && $object->get('id'); + // Full replacement of the attachment/required link is documented and tested + // behavior only for the dedicated "variables" endpoint; 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 && $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(); } try { - if ($wipeInDb) { + if ($wipeValuesInDb) { $objectWhere = $dbAdapter->quoteInto("{$type}_id = ?", $object->get('id')); $dbAdapter->delete('icinga_' . $type . '_var', $objectWhere); - $uuidExpr = DbUtil::quoteBinaryCompat( - DbUtil::binaryResult($object->get('uuid')), - $dbAdapter - ); - $dbAdapter->delete( - 'icinga_' . $type . '_property', - $dbAdapter->quoteInto("{$type}_uuid = ?", $uuidExpr) - ); + if ($wipePropertyAttachmentsInDb) { + $uuidExpr = DbUtil::quoteBinaryCompat( + DbUtil::binaryResult($object->get('uuid')), + $dbAdapter + ); + $dbAdapter->delete( + 'icinga_' . $type . '_property', + $dbAdapter->quoteInto("{$type}_uuid = ?", $uuidExpr) + ); + } $objectVars = new CustomVariables(); } elseif ($replaceAll) { @@ -101,14 +114,14 @@ public function apply( $objectVars->storeToDb($object); } catch (Throwable $e) { - if ($wipeInDb) { + if ($manageTransaction) { $dbAdapter->rollBack(); } throw $e; } - if ($wipeInDb) { + if ($manageTransaction) { $dbAdapter->commit(); } } diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index a49479709..45759e8ca 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -13,6 +13,7 @@ use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Resolver\OverrideHelper; +use Icinga\Web\UrlParams; use InvalidArgumentException; use RuntimeException; use stdClass; @@ -177,6 +178,7 @@ protected function handleApiRequest() $type = $this->getType(); $object = $this->loadOptionalObject(); $actionName = $this->request->getActionName(); + $method = $request->getMethod(); $overRiddenCustomVars = []; $replaceAll = false; @@ -193,7 +195,7 @@ protected function handleApiRequest() // Extract custom vars from the data if (isset($data['vars'])) { $overRiddenCustomVars = (array) $data['vars']; - $replaceAll = $request->getMethod() === 'POST'; + $replaceAll = $method === 'POST'; unset($data['vars']); } @@ -205,68 +207,43 @@ protected function handleApiRequest() unset($data[$key]); } } - - if ($object) { - if ($request->getMethod() === 'POST') { - $object->setProperties($data); - } else { - $data = array_merge([ - 'object_type' => $object->get('object_type'), - 'object_name' => $object->getObjectName() - ], $data); - $object->replaceWith(IcingaObject::createByType($type, $data, $db)); - } - - // Avoid cyclic imports for hosts and commands - if (in_array($object->getShortTableName(), ['host', 'command'], true)) { - if (in_array((int) $object->get('id'), $object->listAncestorIds())) { - throw new RuntimeException( - 'Import loop detected for the object ' - . $object->getObjectName() . ' -> Imports: ' - . implode(', ', $object->getImports()) - ); - } - - if (isset($data['imports']) && in_array($object->get('object_name'), $data['imports'])) { - throw new RuntimeException( - 'You can not import the same object into itself: ' . $object->getObjectName() - ); - } - } - - $this->persistChanges($object); - } elseif ($allowsOverrides && $type === 'service') { - if ($request->getMethod() === 'PUT') { - throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); - } - - $data['vars'] = $overRiddenCustomVars; - $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data); - - return; - } else { - $object = IcingaObject::createByType($type, $data, $db); - $this->persistChanges($object); - } - } - - $isVariablesPut = $actionName === 'variables' && $request->getMethod() === 'PUT'; - if (empty($overRiddenCustomVars) && ! $isVariablesPut && ! $replaceAll) { - $this->sendJson($object->toPlainObject(false, true)); - - break; } - (new CustomVariableValueApplier($db))->apply( + // 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, $object, - $overRiddenCustomVars, + $data, + $type, $actionName, - $request->getMethod(), - $replaceAll - ); - - $object = IcingaObject::loadByType($type, $object->getObjectName(), $db); - $this->sendJson($object->toPlainObject(false, true)); + $method, + $replaceAll, + $overRiddenCustomVars, + $allowsOverrides, + $params + ) { + $responseObject = $this->persistObjectAndApplyVars( + $object, + $data, + $type, + $actionName, + $method, + $replaceAll, + $overRiddenCustomVars, + $allowsOverrides, + $params + ); + }); + + if ($responseObject !== null) { + $this->sendJson($responseObject->toPlainObject(false, true)); + } break; case 'GET': @@ -282,6 +259,98 @@ protected function handleApiRequest() } } + /** + * 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. + * + * @param ?IcingaObject $object The object loaded/matched for this request, or + * null when none was found and $type/$data must be + * used to create one + * @param array $data Remaining request body, with any vars/vars.* keys already + * extracted into $overRiddenCustomVars + * @param string $type Short table name of the object type, e.g. "host" + * @param string $actionName The dispatched action, e.g. "index" or "variables" + * @param string $method HTTP method, "POST" or "PUT" + * @param bool $replaceAll Whether a POST body's "vars" is a full replacement + * @param array $overRiddenCustomVars 2-dimensional array of key => value + * @param bool $allowsOverrides Whether the "allowOverrides" request param was set + * @param UrlParams $params The request's URL parameters + * + * @return ?IcingaObject The object to respond with, or null if a response has + * already been sent (the service-override branch) + */ + protected function persistObjectAndApplyVars( + ?IcingaObject $object, + array $data, + string $type, + string $actionName, + string $method, + bool $replaceAll, + array $overRiddenCustomVars, + bool $allowsOverrides, + UrlParams $params + ): ?IcingaObject { + $db = $this->db; + + if ($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 ($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 ($allowsOverrides && $type === 'service') { + if ($method === 'PUT') { + throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); + } + + $data['vars'] = $overRiddenCustomVars; + $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data); + + return null; + } else { + $object = IcingaObject::createByType($type, $data, $db); + $this->persistChanges($object); + } + } + + $isVariablesPut = $actionName === 'variables' && $method === 'PUT'; + if (empty($overRiddenCustomVars) && ! $isVariablesPut && ! $replaceAll) { + return $object; + } + + (new CustomVariableValueApplier($db))->apply($object, $overRiddenCustomVars, $actionName, $method, $replaceAll); + + return IcingaObject::loadByType($type, $object->getObjectName(), $db); + } + protected function persistChanges(IcingaObject $object) { if ($object->hasBeenModified()) { diff --git a/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php b/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php index 5dddbf679..764650764 100644 --- a/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php +++ b/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php @@ -9,6 +9,7 @@ use Icinga\Module\Director\Test\BaseTestCase; use InvalidArgumentException; use Ramsey\Uuid\Uuid; +use Throwable; class CustomVariableValueApplierTest extends BaseTestCase { @@ -153,6 +154,100 @@ public function testReplaceAllWithNoOverridesClearsEveryVariable(): void ); } + 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( + $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( + $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' + ); + } + private function createTemplate($db): IcingaHost { if (IcingaHost::exists(self::TEMPLATE_NAME, $db)) { diff --git a/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php b/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php index e77e21b9a..6cd65b0f8 100644 --- a/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php +++ b/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php @@ -3,12 +3,101 @@ namespace Tests\Icinga\Module\Director\RestApi; use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Objects\DirectorProperty; +use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\RestApi\IcingaObjectHandler; use Icinga\Module\Director\Test\BaseTestCase; +use Icinga\Web\Request; +use Icinga\Web\Response; +use Icinga\Web\UrlParams; use InvalidArgumentException; +use ReflectionMethod; +use Ramsey\Uuid\Uuid; +use Throwable; class IcingaObjectHandlerTest extends BaseTestCase { + private const PREFIX = '___TEST___'; + private const TEMPLATE_NAME = self::PREFIX . 'webserver-template'; + private const DB_CONNECTION_KEY = self::PREFIX . 'database_connection'; + + public function testObjectChangeAndCustomVarValidationFailureRollBackTogether(): void + { + if ($this->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' => 'original-name', + ]); + $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); + + $threw = false; + try { + $db->runFailSafeTransaction(function () use ($method, $handler, $host) { + $method->invoke( + $handler, + $host, + ['display_name' => 'changed-by-request'], + '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() + ); + }); + } 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( + 'original-name', + $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'); @@ -34,4 +123,28 @@ public function testJsonArrayBodyIsRejected(): void $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(); + } } From 3963d548fad971441f781cf55aa5c1bacdd0c3fd Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 15:19:49 +0200 Subject: [PATCH 150/221] DictionaryItem: Stop erasing an unchanged sensitive value of "0" PHP's empty() treats the string "0" as empty, so an unchanged submit of a sensitive field whose stored value was literally "0" fell through the DUMMYPASSWORD restore check and got silently cleared instead of preserved. --- .../forms/DictionaryElements/DictionaryItem.php | 7 +++++-- .../DictionaryElements/DictionaryItemTest.php | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index bedcde13f..9af3b9434 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -550,12 +550,15 @@ public function getItem(): array } // If a sensitive field still has the DUMMYPASSWORD placeholder, keep the old - // secret. An empty value means the user cleared it on purpose. + // 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() - && ! empty($this->fields['value']) + && $this->fields['value'] !== null + && $this->fields['value'] !== '' ) { $values['value'] = $this->fields['value']; } diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index 56e86d13a..9a7810afd 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -223,6 +223,22 @@ public function testGetItemClearsExistingSensitiveValueWhenInheritedAndExplicitl $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 testMergeChildValuesAttachesMatchingValueByKeyName(): void { $propertyItems = [ From 63eabb10be0b98874b9d710c7fa9422dff4c88bd Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 15:42:28 +0200 Subject: [PATCH 151/221] CustomVarRenderer: Scope dictionary child label and visibility by parent Child key_names are only unique within their own parent dictionary, but label/visibility metadata was keyed globally by the bare child name. A non-sensitive child in one dictionary could overwrite the sensitive flag of an identically-named child in a different dictionary, unmasking it. Adds a test locking in the GROUP BY fix already applied for the Icinga DB metadata query. --- .../Icingadb/CustomVarRenderer.php | 66 +++++++-- .../Icingadb/CustomVarRendererTest.php | 138 ++++++++++++++++++ .../Icingadb/TestableCustomVarRenderer.php | 27 ++++ 3 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php create mode 100644 test/php/library/Director/ProvidedHook/Icingadb/TestableCustomVarRenderer.php diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index 80fbdb1ec..40e94797d 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -46,6 +46,17 @@ class CustomVarRenderer extends CustomVarRendererHook /** @var array Related dictionary field names */ protected $customPropertyDictionaries = []; + /** + * Dictionary child configuration, keyed by parent property key_name and then by + * child key_name. Child key_names are only unique within their own parent + * dictionary, so this must never be flattened into $customVariableConfig - two + * different dictionaries may each have an identically-named child with different + * sensitivity. + * + * @var array + */ + protected $dictionaryChildConfig = []; + /** * Positions of sensitive items within fixed-/dynamic-array custom properties, keyed by * the array's own key_name and then by item position, e.g. ['ssh_args' => ['3' => true]] @@ -302,10 +313,17 @@ public function prefetchForObject(Model $object): bool $this->customPropertyDictionaries[$dictionaryItem->parent_name][$propertyName] = $dictionaryItem->label; if (is_string($propertyName)) { - $this->customVariableConfig[$propertyName] = ['label' => $dictionaryItem->label]; + // Scoped by parent dictionary: two different dictionaries may have a + // child with the same key_name (e.g. both a "password"), and only one + // of them may be sensitive. Keying this globally by the bare child + // name would let a later, non-sensitive dictionary's child silently + // unmask an earlier sensitive one. + $childConfig = ['label' => $dictionaryItem->label]; if ($dictionaryItem->value_type === 'sensitive') { - $this->customVariableConfig[$propertyName]['visibility'] = 'hidden'; + $childConfig['visibility'] = 'hidden'; } + + $this->dictionaryChildConfig[$dictionaryItem->parent_name][$propertyName] = $childConfig; } if (str_starts_with($dictionaryItem->value_type, 'datalist-')) { @@ -456,10 +474,29 @@ protected function getObjectCustomProperties(IcingaObject $object): array return $result; } - public function renderCustomVarKey(string $key) + /** + * Look up dictionary-child-scoped configuration for $key under its immediate + * parent dictionary/array $parentKey, if any + * + * @param string $key + * @param ?string $parentKey + * + * @return ?array + */ + private function dictionaryChildConfigFor(string $key, ?string $parentKey): ?array + { + if ($parentKey !== null && isset($this->dictionaryChildConfig[$parentKey][$key])) { + return $this->dictionaryChildConfig[$parentKey][$key]; + } + + return null; + } + + public function renderCustomVarKey(string $key, ?string $parentKey = null) { try { $label = $this->fieldConfig[$key]['label'] + ?? $this->dictionaryChildConfigFor($key, $parentKey)['label'] ?? $this->customVariableConfig[$key]['label'] ?? null; if ($label === null) { @@ -478,9 +515,11 @@ public function renderCustomVarKey(string $key) return null; } - public function renderCustomVarValue(string $key, $value) + public function renderCustomVarValue(string $key, $value, ?string $parentKey = null) { - if (! (isset($this->fieldConfig[$key]) || isset($this->customVariableConfig[$key]))) { + $childConfig = $this->dictionaryChildConfigFor($key, $parentKey); + + if (! (isset($this->fieldConfig[$key]) || $childConfig !== null || isset($this->customVariableConfig[$key]))) { return null; } @@ -489,7 +528,8 @@ public function renderCustomVarValue(string $key, $value) return '***'; } - if (($this->customVariableConfig[$key]['visibility'] ?? null) === 'hidden') { + $visibility = $childConfig['visibility'] ?? $this->customVariableConfig[$key]['visibility'] ?? null; + if ($visibility === 'hidden') { return '***'; } @@ -583,7 +623,7 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml // 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) ?? $val; + $val = $this->renderCustomVarValue($k, $val, $key) ?? $val; } $val = (array) $val; @@ -604,8 +644,8 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml $this->dictionaryLevel++; foreach ($val as $childKey => $childVal) { - $childVal = $this->renderCustomVarValue($childKey, $childVal) ?? $childVal; - $label = $this->renderCustomVarKey($childKey) ?? $childKey; + $childVal = $this->renderCustomVarValue($childKey, $childVal, $k) ?? $childVal; + $label = $this->renderCustomVarKey($childKey, $k) ?? $childKey; if ( ! in_array($childKey, $this->dictionaryNames) && ! array_key_exists($childKey, $this->customPropertyDictionaries) @@ -636,8 +676,12 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml new HtmlElement( 'tr', Attributes::create(['class' => "level-{$this->dictionaryLevel}"]), - new HtmlElement('th', null, $this->renderCustomVarKey($k) ?? Html::wantHtml($k)), - new HtmlElement('td', null, $this->renderCustomVarValue($k, $val) ?? 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) + ) ) ); } 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..bb3b3f227 --- /dev/null +++ b/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php @@ -0,0 +1,138 @@ +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' + ); + } + + 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..95b10241a --- /dev/null +++ b/test/php/library/Director/ProvidedHook/Icingadb/TestableCustomVarRenderer.php @@ -0,0 +1,27 @@ +dictionaryChildConfig[$parentKey][$childKey] = $config; + } + + public function seedDictionaryName(string $key): void + { + $this->dictionaryNames[] = $key; + } + + public function renderDictionaryValForTest(string $key, array $value) + { + return $this->renderDictionaryVal($key, $value); + } +} From 33d5491ff119925c94cb75eec376cf7bde5faf79 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 15:49:47 +0200 Subject: [PATCH 152/221] CustomVarRenderer: Quote binary UUIDs before use in the metadata IN clause getObjectCustomProperties() built its IN (?) list from raw binary uuid strings, unlike every other query in this module that touches a bytea/ binary column. Under PostgreSQL that quoted the whole array away to nothing, so the query always returned zero rows there even after fixing the earlier GROUP BY error. Route the array through DbUtil::quoteBinaryCompat(), the same helper already used everywhere else for this. --- .../Director/ProvidedHook/Icingadb/CustomVarRenderer.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index 40e94797d..f662e5328 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -10,6 +10,7 @@ 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; @@ -426,7 +427,8 @@ protected function getObjectCustomProperties(IcingaObject $object): array } $uuids[] = $object->get('uuid'); - $query = $db->getDbAdapter() + $dbAdapter = $db->getDbAdapter(); + $query = $dbAdapter ->select() ->from( ['dp' => 'director_property'], @@ -443,7 +445,7 @@ protected function getObjectCustomProperties(IcingaObject $object): array ->join(['iop' => "icinga_$type" . '_property'], 'dp.uuid = iop.property_uuid', []) ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) ->joinLeft(['cpc' => 'director_datafield_category'], 'dp.category_id = cpc.id', []) - ->where('iop.' . $type . '_uuid IN (?)', $uuids) + ->where('iop.' . $type . '_uuid IN (?)', DbUtil::quoteBinaryCompat($uuids, $dbAdapter)) ->group([ 'dp.uuid', 'dp.key_name', From a32cec1c1a06906986a20b0edae2610704c5cd5a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 16:33:32 +0200 Subject: [PATCH 153/221] ObjectController: Custom variables with space should be accessible by $value["custom var"]$ --- library/Director/Web/Controller/ObjectController.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index d7e401e8a..afa1a0067 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -753,10 +753,6 @@ private function prepareApplyForHeader(): void $content = []; $configVariables = new HtmlElement('table', Attributes::create(['class' => 'key-value-table'])); foreach ($dictionaryKeys as $keyAttributes) { - if (str_contains($keyAttributes['key_name'], ' ')) { - continue; - } - if (preg_match('/[^a-zA-Z0-9_]/', $keyAttributes['key_name'])) { $config = '$value["' . $keyAttributes['key_name'] . '"]'; } else { @@ -782,10 +778,6 @@ private function prepareApplyForHeader(): void $nestedContent = []; foreach ($this->fetchNestedDictionaryKeys($keyAttributes['uuid']) as $nestedKeyAttributes) { - if (str_contains($nestedKeyAttributes['key_name'], ' ')) { - continue; - } - if (preg_match('/[^a-zA-Z0-9_]/', $nestedKeyAttributes['key_name'])) { $nestedConfig = $config . '["' . $nestedKeyAttributes['key_name'] . '"]$'; } else { From b0cdd010d74dd08087021103a62c774eed24150a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 22:10:02 +0200 Subject: [PATCH 154/221] IcingaConfigHelper: Allow quoted dictionary keys in wildcard macro checks value[*] was restricted to digit-only indexes, which broke access to custom variables with spaces in their name via value["custom var"]. Widen it to also accept a quoted key, while still excluding both '"' and '\' so a crafted key can't escape out of the quoted literal. Also drop the per-key_name whitelist entries built from raw DB content in IcingaService::vars() in favor of the same value.* wildcard, and scope the dictionary-only patterns (value[*], value[*].*, value.*) to apply only when the apply-for source is actually a dynamic-dictionary. --- .../IcingaConfig/IcingaConfigHelper.php | 11 ++++----- library/Director/Objects/IcingaService.php | 24 ++++--------------- .../IcingaConfig/IcingaConfigHelperTest.php | 17 +++++++------ 3 files changed, 18 insertions(+), 34 deletions(-) diff --git a/library/Director/IcingaConfig/IcingaConfigHelper.php b/library/Director/IcingaConfig/IcingaConfigHelper.php index da439c047..a2872347c 100644 --- a/library/Director/IcingaConfig/IcingaConfigHelper.php +++ b/library/Director/IcingaConfig/IcingaConfigHelper.php @@ -408,13 +408,10 @@ public static function isValidMacroName(string $name, ?array $whiteList = null): } $regexBody = preg_quote($pattern, '/'); - // A wildcard enclosed in literal brackets stands for an array index: - // digits only, e.g. value[*] must only ever match value[0], value[12], ... - $regexBody = str_replace('\[\*\]', '\[\d+\]', $regexBody); - // Any other wildcard stands for exactly one safe macro-name segment: the - // same character class the base macro pattern allows, never arbitrary - // text. This is what keeps a value like `host.name) { throw "injected"` - // from matching `host.*`. + // 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)) { diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index af5561e54..40d51d9fd 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -15,7 +15,6 @@ use Icinga\Module\Director\Objects\Extension\FlappingSupport; use Icinga\Module\Director\Resolver\HostServiceBlacklist; use InvalidArgumentException; -use PDO; use RuntimeException; class IcingaService extends IcingaObject implements ExportInterface @@ -716,20 +715,13 @@ public function vars() } if ($this->applyForWhiteList === null) { - $query = $this->db - ->select() - ->from(['dp' => 'director_property'], ['key_name' => 'dp.key_name']) - ->join(['parent_dp' => 'director_property'], 'dp.parent_uuid = parent_dp.uuid', []) - ->where('parent_dp.parent_uuid IS NULL') - ->where("parent_dp.value_type = 'dynamic-dictionary'") - ->where("parent_dp.key_name = ?", $applyFor); - - $result = $this->db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); - $isApplyForDictionary = $this->fetchApplyForPropertyType($applyFor) === 'dynamic-dictionary'; - $whiteList = ['value', 'host.*', 'value[*]', 'value[*].*']; + $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$. @@ -738,14 +730,6 @@ public function vars() $whiteList['config'] = 'value'; } - foreach ($result as $row) { - if (str_contains($row['key_name'], ' ')) { - continue; - } - - $whiteList[] = sprintf('value.%s', $row['key_name']); - } - $this->applyForWhiteList = $whiteList; } diff --git a/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php index a2b01c88a..846f73acf 100644 --- a/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php +++ b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php @@ -187,20 +187,14 @@ public function testIsValidMacroNameWhitelistOverridesPatternCheck() public function testIsValidMacroNameWildcardDoesNotBypassSyntaxCheck(): void { - // A wildcard entry must only match a single safe macro-name segment (or, inside - // brackets, a numeric index). It must not let a non-macro token (e.g. an - // attempted DSL injection payload) through just because it superficially starts - // with the wildcard's literal prefix. $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 { - // These are the actual production whitelist entries built in - // IcingaService::vars() for array/dictionary apply-for rendering. A wildcard - // fix must not regress legitimate use of any of them. $whiteList = ['value', 'host.*', 'value[*]', 'value[*].*']; $this->assertTrue(c::isValidMacroName('host.vars.custom', $whiteList)); @@ -210,4 +204,13 @@ public function testIsValidMacroNameWildcardStillMatchesArrayIndexAndDictionaryF $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)); + } } From 1883c356e41317c6e87fd6cf7f1302bcafae5586 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 22:39:37 +0200 Subject: [PATCH 155/221] IcingaConfigHelperTest: Use real-world plugin and macro examples Replace placeholder names (var1, name1, foo-ish dictionary keys) with values that resemble actual Icinga usage: check plugin paths, macro names like address/display_name/check_command, and a dictionary key with a space to match the custom-variable naming this module allows. --- .../IcingaConfig/IcingaConfigHelperTest.php | 52 +++++++++---------- .../Director/IcingaConfig/rendered/dict1.out | 4 +- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php b/test/php/library/Director/IcingaConfig/IcingaConfigHelperTest.php index 846f73acf..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,45 @@ 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$\$') ); } @@ -159,14 +159,14 @@ public function testIsValidMacroNameWildcardWhitelistMatch() { $this->assertTrue(c::isValidMacroName('value.mount_point', ['value.*'])); $this->assertTrue(c::isValidMacroName('value.warn', ['value.*'])); - $this->assertTrue(c::isValidMacroName('host.custom', ['host.*', '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', ['other.field'])); + $this->assertFalse(c::isValidMacroName('host.vars.custom', ['check_command'])); } public function testIsValidMacroNameEmptyWhitelistReturnsFalse() 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 } From 9d9add3df6fdf0410a50c6805133a8d0b0977e96 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 22:55:16 +0200 Subject: [PATCH 156/221] CI: Clone icingadb-web module for PHPUnit run CustomVarRendererTest now needs Icingadb\Hook\CustomVarRendererHook, which only the phpstan workflow was pulling in. Fetch and enable icingadb-web the same way the test job already does for incubator. --- .github/workflows/php.yml | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 3618db8c1b53d6530d2479338f09e8e985bfc118 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 23:09:01 +0200 Subject: [PATCH 157/221] IcingaServiceApplyForTest: Update test to match current whitelist scoping testUnknownValueFieldStrippedFromVars assumed value.* was only whitelisted for a dictionary's declared children, which stopped being true once IcingaService::vars() switched to the value.* wildcard. Replace it with two tests that match current behavior: an unrelated macro still gets quoted, and value.* dot access stays quoted when the apply-for source is a plain array rather than a dictionary. --- .../Objects/IcingaServiceApplyForTest.php | 76 +++++++++++++++---- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/test/php/library/Director/Objects/IcingaServiceApplyForTest.php b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php index 300fd1474..6e2d435a5 100644 --- a/test/php/library/Director/Objects/IcingaServiceApplyForTest.php +++ b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php @@ -183,7 +183,10 @@ public function testValueFieldMacroAllowedInVars(): void ], $db); $child->store(); - $service = $this->applyService('disk-macro-check', 'host.vars.' . self::PREFIX . 'disk_checks_macro'); + $service = $this->applyService( + 'disk-macro-check', + 'host.vars.' . self::PREFIX . 'disk_checks_macro' + ); $service->setConnection($db); $service->{'vars.mount'} = '$value.mount_point$'; @@ -196,34 +199,79 @@ public function testValueFieldMacroAllowedInVars(): void ); } - public function testUnknownValueFieldStrippedFromVars(): void + public function testQuotedDictionaryKeyMacroAllowedInVars(): void { if ($this->skipForMissingDb()) { return; } $db = $this->getDb(); + $host = $this->hostTemplate(); + $host->store($db); - // Create disk_checks (dynamic-dictionary) with NO children — so value.not_a_real_field won't be whitelisted - $parent = DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => self::PREFIX . 'disk_checks_strip', - 'value_type' => 'dynamic-dictionary', - 'label' => 'Disk Checks Strip', - ], $db); - $parent->store(); - $this->createdPropertyKeys[] = self::PREFIX . 'disk_checks_strip'; + $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'} = '$value.not_a_real_field$'; + $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.secret = "$value.not_a_real_field$"', + 'vars.mount = "$value.mount_point$"', $rendered, - 'Non-whitelisted macro must render as quoted string' + 'value.* dot access only makes sense for a dynamic-dictionary apply-for' + .' and must stay quoted for a plain array' ); } From 233cb93eaabbd0c3a7bd2e904cf4c0679c0d8692 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 23:26:44 +0200 Subject: [PATCH 158/221] RestApi: Collapse long parameter lists into request value objects CustomVariableValueApplier::apply() and IcingaObjectHandler::persistObjectAndApplyVars() had grown too many positional arguments to read or call safely. Introduce CustomVarApplyRequest and IcingaObjectWriteRequest to carry the same data instead. --- .../RestApi/CustomVarApplyRequest.php | 28 ++++++ .../RestApi/CustomVariableValueApplier.php | 54 ++++------- .../Director/RestApi/IcingaObjectHandler.php | 91 ++++++++----------- .../RestApi/IcingaObjectWriteRequest.php | 40 ++++++++ .../CustomVariableValueApplierTest.php | 37 ++++---- .../RestApi/IcingaObjectHandlerTest.php | 34 +++---- 6 files changed, 157 insertions(+), 127 deletions(-) create mode 100644 library/Director/RestApi/CustomVarApplyRequest.php create mode 100644 library/Director/RestApi/IcingaObjectWriteRequest.php 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 index 36603923b..3e7691b6d 100644 --- a/library/Director/RestApi/CustomVariableValueApplier.php +++ b/library/Director/RestApi/CustomVariableValueApplier.php @@ -27,35 +27,25 @@ public function __construct(private Db $db) * * When the method is PUT, every custom variable currently set directly on * the object is removed first and only the given overrides survive. - * When $replaceAll is set for a non PUT request, the same end result + * When replaceAll is set for a non PUT request, the same end result * is reached without touching rows that are not affected, which is * used for a POST carrying a full "vars" dictionary at the base * object endpoint. * - * @param IcingaObject $object - * @param array $overRiddenCustomVars 2-dimensional array of key => value - * @param string $actionName Endpoint name - * @param string $method POST or PUT - * @param bool $replaceAll - * * @throws NotFoundError */ - public function apply( - IcingaObject $object, - array $overRiddenCustomVars, - string $actionName, - string $method, - bool $replaceAll - ): void { + public function apply(CustomVarApplyRequest $request): void + { + $object = $request->object; $dbAdapter = $this->db->getDbAdapter(); $type = $object->getShortTableName(); $objectVars = $object->vars(); - $wipeValuesInDb = $method === 'PUT' && $object->get('id'); + $wipeValuesInDb = $request->method === 'PUT' && $object->get('id'); // Full replacement of the attachment/required link is documented and tested // behavior only for the dedicated "variables" endpoint; 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 && $actionName === 'variables'; + $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. @@ -84,10 +74,10 @@ public function apply( } $objectVars = new CustomVariables(); - } elseif ($replaceAll) { + } elseif ($request->replaceAll) { $obsoleteKeys = []; foreach ($objectVars as $key => $var) { - if (! array_key_exists($key, $overRiddenCustomVars)) { + if (! array_key_exists($key, $request->overRiddenCustomVars)) { $obsoleteKeys[] = $key; } } @@ -99,17 +89,8 @@ public function apply( $customProperties = $this->getObjectCustomProperties($object); - foreach ($overRiddenCustomVars as $key => $value) { - $this->applySingleVar( - $object, - $objectVars, - $customProperties, - $key, - $value, - $actionName, - $method, - $type - ); + foreach ($request->overRiddenCustomVars as $key => $value) { + $this->applySingleVar($request, $objectVars, $customProperties, $key, $value); } $objectVars->storeToDb($object); @@ -130,20 +111,16 @@ public function apply( * 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( - IcingaObject $object, + CustomVarApplyRequest $request, CustomVariables $objectVars, array $customProperties, string $key, - mixed $value, - string $actionName, - string $method, - string $type + mixed $value ): void { + $object = $request->object; $objectVars->set($key, $value); $var = $objectVars->get($key); if ($var === null) { @@ -169,7 +146,7 @@ private function applySingleVar( return; } - if ($actionName !== 'variables') { + if ($request->actionName !== 'variables') { return; } @@ -180,7 +157,7 @@ private function applySingleVar( )); } - if ($method === 'POST') { + if ($request->method === 'POST') { throw new NotFoundError(sprintf( 'The custom variable %s should be first added to the template', $key @@ -213,6 +190,7 @@ private function applySingleVar( ); } + $type = $object->getShortTableName(); $dbAdapter->insert('icinga_' . $type . '_property', [ 'property_uuid' => DbUtil::quoteBinaryCompat($customPropertyUuid, $dbAdapter), $type . '_uuid' => DbUtil::quoteBinaryCompat($object->get('uuid'), $dbAdapter) diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index 45759e8ca..dbc100cda 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -13,7 +13,6 @@ use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Resolver\OverrideHelper; -use Icinga\Web\UrlParams; use InvalidArgumentException; use RuntimeException; use stdClass; @@ -209,15 +208,7 @@ protected function handleApiRequest() } } - // 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 = new IcingaObjectWriteRequest( $object, $data, $type, @@ -227,18 +218,17 @@ protected function handleApiRequest() $overRiddenCustomVars, $allowsOverrides, $params - ) { - $responseObject = $this->persistObjectAndApplyVars( - $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) { @@ -267,36 +257,17 @@ protected function handleApiRequest() * custom-variable step must not leave the object's own property changes * committed on their own. * - * @param ?IcingaObject $object The object loaded/matched for this request, or - * null when none was found and $type/$data must be - * used to create one - * @param array $data Remaining request body, with any vars/vars.* keys already - * extracted into $overRiddenCustomVars - * @param string $type Short table name of the object type, e.g. "host" - * @param string $actionName The dispatched action, e.g. "index" or "variables" - * @param string $method HTTP method, "POST" or "PUT" - * @param bool $replaceAll Whether a POST body's "vars" is a full replacement - * @param array $overRiddenCustomVars 2-dimensional array of key => value - * @param bool $allowsOverrides Whether the "allowOverrides" request param was set - * @param UrlParams $params The request's URL parameters - * * @return ?IcingaObject The object to respond with, or null if a response has * already been sent (the service-override branch) */ - protected function persistObjectAndApplyVars( - ?IcingaObject $object, - array $data, - string $type, - string $actionName, - string $method, - bool $replaceAll, - array $overRiddenCustomVars, - bool $allowsOverrides, - UrlParams $params - ): ?IcingaObject { + protected function persistObjectAndApplyVars(IcingaObjectWriteRequest $request): ?IcingaObject + { $db = $this->db; + $object = $request->object; + $data = $request->data; + $type = $request->type; - if ($actionName !== 'variables') { + if ($request->actionName !== 'variables') { if ($object) { // Avoid cyclic imports for hosts and commands if (in_array($object->getShortTableName(), ['host', 'command'], true)) { @@ -315,7 +286,7 @@ protected function persistObjectAndApplyVars( } } - if ($method === 'POST') { + if ($request->method === 'POST') { $object->setProperties($data); } else { $data = array_merge([ @@ -326,13 +297,17 @@ protected function persistObjectAndApplyVars( } $this->persistChanges($object); - } elseif ($allowsOverrides && $type === 'service') { - if ($method === 'PUT') { + } elseif ($request->allowsOverrides && $type === 'service') { + if ($request->method === 'PUT') { throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); } - $data['vars'] = $overRiddenCustomVars; - $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data); + $data['vars'] = $request->overRiddenCustomVars; + $this->setServiceProperties( + $request->params->getRequired('host'), + $request->params->getRequired('name'), + $data + ); return null; } else { @@ -341,12 +316,18 @@ protected function persistObjectAndApplyVars( } } - $isVariablesPut = $actionName === 'variables' && $method === 'PUT'; - if (empty($overRiddenCustomVars) && ! $isVariablesPut && ! $replaceAll) { + $isVariablesPut = $request->actionName === 'variables' && $request->method === 'PUT'; + if (empty($request->overRiddenCustomVars) && ! $isVariablesPut && ! $request->replaceAll) { return $object; } - (new CustomVariableValueApplier($db))->apply($object, $overRiddenCustomVars, $actionName, $method, $replaceAll); + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( + $object, + $request->overRiddenCustomVars, + $request->actionName, + $request->method, + $request->replaceAll + )); return IcingaObject::loadByType($type, $object->getObjectName(), $db); } 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/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php b/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php index 764650764..e89cc3d5e 100644 --- a/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php +++ b/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php @@ -5,6 +5,7 @@ use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\Objects\DirectorProperty; use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\RestApi\CustomVarApplyRequest; use Icinga\Module\Director\RestApi\CustomVariableValueApplier; use Icinga\Module\Director\Test\BaseTestCase; use InvalidArgumentException; @@ -27,13 +28,13 @@ public function testNullValueForUnsetVariableDoesNotCrash(): void $db = $this->getDb(); $host = $this->createTemplate($db); - (new CustomVariableValueApplier($db))->apply( + (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')); @@ -49,25 +50,25 @@ public function testFailedValidationRollsBackFullReplace(): void $host = $this->createTemplate($db); $this->attachProperties($host, $db); - (new CustomVariableValueApplier($db))->apply( + (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 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 @@ -91,23 +92,23 @@ public function testReplaceAllRemovesUnmentionedVariablesOnPost(): void $host = $this->createTemplate($db); $this->attachProperties($host, $db); - (new CustomVariableValueApplier($db))->apply( + (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 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( @@ -127,25 +128,25 @@ public function testReplaceAllWithNoOverridesClearsEveryVariable(): void $host = $this->createTemplate($db); $this->attachProperties($host, $db); - (new CustomVariableValueApplier($db))->apply( + (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 CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( $host, [], 'index', 'POST', true - ); + )); $host = IcingaHost::load(self::TEMPLATE_NAME, $db); $this->assertNull( @@ -168,13 +169,13 @@ public function testApplyDoesNotCommitOrRollBackWhenAlreadyInsideATransaction(): $dbAdapter->beginTransaction(); try { - (new CustomVariableValueApplier($db))->apply( + (new CustomVariableValueApplier($db))->apply(new CustomVarApplyRequest( $host, [self::ENV_KEY => 'production'], 'variables', 'PUT', false - ); + )); } catch (Throwable $e) { $dbAdapter->rollBack(); $this->fail( @@ -221,13 +222,13 @@ public function testBaseObjectPutKeepsPropertyAttachmentsIntact(): void // 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 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()); diff --git a/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php b/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php index 6cd65b0f8..1ff853d33 100644 --- a/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php +++ b/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php @@ -7,6 +7,7 @@ use Icinga\Module\Director\Objects\DirectorProperty; use Icinga\Module\Director\Objects\IcingaHost; use Icinga\Module\Director\RestApi\IcingaObjectHandler; +use Icinga\Module\Director\RestApi\IcingaObjectWriteRequest; use Icinga\Module\Director\Test\BaseTestCase; use Icinga\Web\Request; use Icinga\Web\Response; @@ -59,24 +60,25 @@ public function testObjectChangeAndCustomVarValidationFailureRollBackTogether(): $method = new ReflectionMethod($handler, 'persistObjectAndApplyVars'); $method->setAccessible(true); + $writeRequest = new IcingaObjectWriteRequest( + $host, + ['display_name' => 'changed-by-request'], + '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, $host) { - $method->invoke( - $handler, - $host, - ['display_name' => 'changed-by-request'], - '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() - ); + $db->runFailSafeTransaction(function () use ($method, $handler, $writeRequest) { + $method->invoke($handler, $writeRequest); }); } catch (Throwable $e) { $threw = true; From 8f76e47bbdee37c191b49c63eaeab6c43f7841d7 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 23:33:48 +0200 Subject: [PATCH 159/221] IcingaServiceApplyForTest: Attach disk_checks_macro property to a host fetchApplyForPropertyType() only recognizes a property as the apply-for source once it is linked to a host via icinga_host_property, so testValueFieldMacroAllowedInVars never saw its property as a dynamic-dictionary and the value.mount_point macro stayed quoted. --- .../Director/Objects/IcingaServiceApplyForTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/php/library/Director/Objects/IcingaServiceApplyForTest.php b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php index 6e2d435a5..1496d56d5 100644 --- a/test/php/library/Director/Objects/IcingaServiceApplyForTest.php +++ b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php @@ -164,6 +164,8 @@ public function testValueFieldMacroAllowedInVars(): void } $db = $this->getDb(); + $host = $this->hostTemplate(); + $host->store($db); // Create disk_checks (dynamic-dictionary) with child mount_point $parent = DirectorProperty::create([ @@ -183,6 +185,12 @@ public function testValueFieldMacroAllowedInVars(): void ], $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' From 8c38f89ee25487cf371e56169d9f3cbbe914d38d Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 16 Jul 2026 23:36:55 +0200 Subject: [PATCH 160/221] IcingaServiceApplyForTest: Resolve php cs errorst --- test/php/library/Director/Objects/IcingaServiceApplyForTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/php/library/Director/Objects/IcingaServiceApplyForTest.php b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php index 1496d56d5..98ad7ea09 100644 --- a/test/php/library/Director/Objects/IcingaServiceApplyForTest.php +++ b/test/php/library/Director/Objects/IcingaServiceApplyForTest.php @@ -279,7 +279,7 @@ public function testDotAccessRejectedForArrayApplyFor(): void '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' + . ' and must stay quoted for a plain array' ); } From 6fc9de69710f268b88d32a80a4ad228ded36adc7 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 17 Jul 2026 08:11:23 +0200 Subject: [PATCH 161/221] CustomVariableForm: Relink datalist when only the selected list changes updateExistingProperty() only touched director_property_datalist inside the value_type-change branch, so swapping to a different datalist while keeping the same value_type (e.g. datalist-strict -> datalist-strict) silently left the property pointing at the old list. --- application/forms/CustomVariableForm.php | 37 +++++++++ .../Director/Form/CustomVariableFormTest.php | 83 ++++++++++++++++++- 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index da00f07e7..d6cb50322 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -596,6 +596,8 @@ private function updateExistingProperty( ]); } } + } elseif (str_starts_with($valueType, 'datalist-') && ! empty($datalist)) { + $this->relinkDatalist($this->uuid->getBytes(), $datalist); } } else { $storedKeyName = $this->db->fetchOne( @@ -619,6 +621,41 @@ private function updateExistingProperty( ); } + /** + * 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), + ]); + } + /** * Recursively collect the raw binary UUIDs of all descendants (children, * grandchildren, ...) of the property with the given raw binary UUID. diff --git a/test/php/library/Director/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php index 7974f4ca9..2e501ed54 100644 --- a/test/php/library/Director/Form/CustomVariableFormTest.php +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -6,6 +6,7 @@ namespace Tests\Icinga\Module\Director\Form; use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Objects\DirectorDatalist; use Icinga\Module\Director\Test\BaseTestCase; use Tests\Icinga\Module\Director\Form\Lib\TestableCustomVariableForm; @@ -14,6 +15,9 @@ 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 = []; + public function testAddStringPropertyCreatesRow(): void { if ($this->skipForMissingDb()) { @@ -177,10 +181,81 @@ public function testFailedCreateDoesNotLeaveTransactionOpen(): void $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 tearDown(): void { if ($this->hasDb()) { - $dba = $this->getDb()->getDbAdapter(); + $db = $this->getDb(); + $dba = $db->getDbAdapter(); foreach ($this->createdKeyNames as $keyName) { $rows = $dba->fetchAll( $dba->select() @@ -198,6 +273,12 @@ public function tearDown(): void $dba->delete('director_property', $dba->quoteInto('key_name = ?', $keyName)); } } + + foreach ($this->createdDatalistNames as $listName) { + if (DirectorDatalist::exists($listName, $db)) { + DirectorDatalist::load($listName, $db)->delete(); + } + } } parent::tearDown(); From 536abd5aa644d48f3a74293f24596f96e4a78bd3 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 17 Jul 2026 08:40:33 +0200 Subject: [PATCH 162/221] CustomVariableValueApplier: clean up the code --- library/Director/RestApi/CustomVariableValueApplier.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/library/Director/RestApi/CustomVariableValueApplier.php b/library/Director/RestApi/CustomVariableValueApplier.php index 3e7691b6d..ad9d48a9f 100644 --- a/library/Director/RestApi/CustomVariableValueApplier.php +++ b/library/Director/RestApi/CustomVariableValueApplier.php @@ -32,6 +32,8 @@ public function __construct(private Db $db) * used for a POST carrying a full "vars" dictionary at the base * object endpoint. * + * @return void + * * @throws NotFoundError */ public function apply(CustomVarApplyRequest $request): void @@ -41,9 +43,7 @@ public function apply(CustomVarApplyRequest $request): void $type = $object->getShortTableName(); $objectVars = $object->vars(); $wipeValuesInDb = $request->method === 'PUT' && $object->get('id'); - // Full replacement of the attachment/required link is documented and tested - // behavior only for the dedicated "variables" endpoint; a PUT on the base - // object endpoint must still fully replace values but must not detach + // 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'; @@ -111,6 +111,8 @@ public function apply(CustomVarApplyRequest $request): void * 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( From febb86cb5a42ce5ee8000f1c58af8cf7800f58b6 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 17 Jul 2026 11:27:23 +0200 Subject: [PATCH 163/221] IcingaObjectHandler: Set allowOverrides param by default to false --- library/Director/RestApi/IcingaObjectHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php index dbc100cda..73bda4a8a 100644 --- a/library/Director/RestApi/IcingaObjectHandler.php +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -173,7 +173,7 @@ protected function handleApiRequest() case 'PUT': $data = (array) $this->requireJsonBody(); $params = $this->request->getUrl()->getParams(); - $allowsOverrides = $params->get('allowOverrides'); + $allowsOverrides = $params->get('allowOverrides', false); $type = $this->getType(); $object = $this->loadOptionalObject(); $actionName = $this->request->getActionName(); From 5833c0d67812306fbfa3be7aa2208399d6d2e2b3 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 17 Jul 2026 14:22:13 +0200 Subject: [PATCH 164/221] DictionaryItem: Add missing return statement to populate() --- application/forms/DictionaryElements/DictionaryItem.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index 9af3b9434..575735edc 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -296,7 +296,7 @@ public function populate($values) } } - parent::populate($values); + return parent::populate($values); } /** From adfa3e5957d89bb098c897d6d5e35e28ce8ef87e Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Fri, 17 Jul 2026 14:56:36 +0200 Subject: [PATCH 165/221] CustomVariableForm: Preserve item typ for datalists on saving to DB --- application/forms/CustomVariableForm.php | 32 +++++- .../Director/Form/CustomVariableFormTest.php | 97 +++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index d6cb50322..6e03edf16 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -596,8 +596,14 @@ private function updateExistingProperty( ]); } } - } elseif (str_starts_with($valueType, 'datalist-') && ! empty($datalist)) { - $this->relinkDatalist($this->uuid->getBytes(), $datalist); + } 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( @@ -656,6 +662,28 @@ private function relinkDatalist(string $propertyUuid, array $datalist): void ]); } + /** + * 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. diff --git a/test/php/library/Director/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php index 2e501ed54..c485b4295 100644 --- a/test/php/library/Director/Form/CustomVariableFormTest.php +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -251,6 +251,103 @@ public function testUpdateDatalistPropertyRelinksToNewlySelectedDatalist(): void ); } + 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 tearDown(): void { if ($this->hasDb()) { From bb85e2c321a04d43944f4ce735f245fbf002d45a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 20 Jul 2026 10:25:22 +0200 Subject: [PATCH 166/221] HostController: Replace deprecated ON_SUCCESS with ON_SUBMIT for HostServiceBlacklistForm --- application/controllers/HostController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php index 1724f5700..75799ec85 100644 --- a/application/controllers/HostController.php +++ b/application/controllers/HostController.php @@ -467,7 +467,7 @@ public function appliedserviceAction() ); $deactivateForm = (new HostServiceBlacklistForm($db, $host, $parent)) - ->on(HostServiceBlacklistForm::ON_SUCCESS, function () { + ->on(HostServiceBlacklistForm::ON_SUBMIT, function () { $this->redirectNow(Url::fromRequest()); }) ->handleRequest($this->getServerRequest()); @@ -586,7 +586,7 @@ public function inheritedserviceAction() 'host_id' => $from->get('id'), ], $db); $deactivateForm = (new HostServiceBlacklistForm($db, $host, $parent)) - ->on(HostServiceBlacklistForm::ON_SUCCESS, function () { + ->on(HostServiceBlacklistForm::ON_SUBMIT, function () { $this->redirectNow(Url::fromRequest()); }) ->handleRequest($this->getServerRequest()); @@ -681,7 +681,7 @@ public function servicesetserviceAction() ); $deactivateForm = (new HostServiceBlacklistForm($db, $host, $originalService)) - ->on(HostServiceBlacklistForm::ON_SUCCESS, function () { + ->on(HostServiceBlacklistForm::ON_SUBMIT, function () { $this->redirectNow(Url::fromRequest()); }) ->handleRequest($this->getServerRequest()); From 314bfd20ff9cff067dace43e5f21701382afe4c1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 20 Jul 2026 11:25:31 +0200 Subject: [PATCH 167/221] Replace deprecated createCsrfCounterMeasure() with addCsrfCounterMeasure() in forms --- application/forms/CustomVariableForm.php | 2 +- application/forms/DeleteCustomVariableForm.php | 2 +- application/forms/HostServiceBlacklistForm.php | 2 +- application/forms/ObjectCustomvarForm.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index 6e03edf16..577c4d3c1 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -125,7 +125,7 @@ public function hasBeenSubmitted(): bool protected function assemble(): void { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addCsrfCounterMeasure(Session::getSession()->getId()); $this->addElement('hidden', 'used_count', ['ignore' => true]); $used = (int) $this->getValue('used_count') > 0; $pendingRename = $this->isPendingRenameConfirmation(); diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index 9a5d8d641..b6eecef26 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -166,7 +166,7 @@ function (ListItem $item, $data) use (&$objectClass, $usageList) { $this->addHtml($usageList); - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addCsrfCounterMeasure(Session::getSession()->getId()); $this->addElement('submit', 'submit', [ 'label' => $this->translate('Delete'), 'class' => 'btn-remove' diff --git a/application/forms/HostServiceBlacklistForm.php b/application/forms/HostServiceBlacklistForm.php index 52c4de163..fd9e3f13b 100644 --- a/application/forms/HostServiceBlacklistForm.php +++ b/application/forms/HostServiceBlacklistForm.php @@ -43,7 +43,7 @@ public function __construct( protected function assemble(): void { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addCsrfCounterMeasure(Session::getSession()->getId()); $blacklisted = $this->hasBeenBlacklisted(); $this->addElement('submit', 'submit', [ 'label' => $blacklisted diff --git a/application/forms/ObjectCustomvarForm.php b/application/forms/ObjectCustomvarForm.php index c13023575..9b2985084 100644 --- a/application/forms/ObjectCustomvarForm.php +++ b/application/forms/ObjectCustomvarForm.php @@ -40,7 +40,7 @@ public function getPropertyName(): string protected function assemble(): void { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addCsrfCounterMeasure(Session::getSession()->getId()); $propertyElement = $this->createElement( 'select', 'property', From 84b7d07d7ca5678b30840a479f06b7949f01d4fb Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 20 Jul 2026 11:57:04 +0200 Subject: [PATCH 168/221] IplBoolean: Avoid using null as an array offset --- library/Director/Web/Form/Element/IplBoolean.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Director/Web/Form/Element/IplBoolean.php b/library/Director/Web/Form/Element/IplBoolean.php index 65d884502..6706681f4 100644 --- a/library/Director/Web/Form/Element/IplBoolean.php +++ b/library/Director/Web/Form/Element/IplBoolean.php @@ -22,7 +22,7 @@ public function __construct($name, $attributes = null) ]; if (! $this->isRequired()) { $options = [ - null => $this->translate('- Please choose -'), + '' => $this->translate('- Please choose -'), ] + $options; } From 13ad21f0be6c3d737f2de988f35cc685bf61328a Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 20 Jul 2026 12:55:49 +0200 Subject: [PATCH 169/221] Restore required flag when relinking already attached custom properties --- .../BasketSnapshotCustomVariableResolver.php | 11 ++++- .../BasketSnapshotCustomVariableTest.php | 48 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php index 9f0122c92..369105e82 100644 --- a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php +++ b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php @@ -128,11 +128,20 @@ public function relinkObjectCustomProperties(IcingaObject $new, $object): void $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) + 'property_uuid' => DbUtil::quoteBinaryCompat(Uuid::fromString($uuid)->getBytes(), $db), + 'required' => $property->required ?? 'n', ]); } } diff --git a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php index c6e948eab..9f49e3161 100644 --- a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php +++ b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php @@ -105,6 +105,54 @@ public function testRestoreBindsPropertyToTemplate(): void $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()) { From 767308d3e9e9c32d2ef58e6326e4f59f396f3072 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 20 Jul 2026 13:23:03 +0200 Subject: [PATCH 170/221] DirectorProperty: Make key_name unique constraint case insensitive on PostgreSQL MySQL already compares key_name case insensitively via utf8mb4_unicode_ci, but PostgreSQL's plain varchar column let "foo" and "FOO" coexist as separate properties. Switch the column to citext so both engines enforce the same uniqueness rule, and document the extra CREATE EXTENSION citext step for PostgreSQL installs alongside the existing pgcrypto one. --- doc/02-Installation.md | 3 +- schema/pgsql-migrations/upgrade_193.sql | 2 +- schema/pgsql.sql | 2 +- .../Director/Objects/DirectorPropertyTest.php | 31 +++++++++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) 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/schema/pgsql-migrations/upgrade_193.sql b/schema/pgsql-migrations/upgrade_193.sql index 7c3edfb62..51ffaa08a 100644 --- a/schema/pgsql-migrations/upgrade_193.sql +++ b/schema/pgsql-migrations/upgrade_193.sql @@ -14,7 +14,7 @@ CREATE TYPE enum_property_value_type AS ENUM( CREATE TABLE director_property ( uuid bytea CHECK(LENGTH(uuid) = 16) NOT NULL, parent_uuid bytea CHECK(LENGTH(parent_uuid) = 16) DEFAULT NULL, - key_name character varying(255) NOT NULL, + key_name citext NOT NULL, label character varying(255) DEFAULT NULL, description text DEFAULT NULL, value_type enum_property_value_type NOT NULL, diff --git a/schema/pgsql.sql b/schema/pgsql.sql index a4c4a1ed2..fe5d7a162 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -334,7 +334,7 @@ CREATE INDEX director_datafield_datafield ON director_datafield_setting (datafie CREATE TABLE director_property ( uuid bytea CHECK(LENGTH(uuid) = 16) NOT NULL, parent_uuid bytea CHECK(LENGTH(parent_uuid) = 16) DEFAULT NULL, - key_name character varying(255) NOT NULL, + key_name citext NOT NULL, label character varying(255) DEFAULT NULL, description text DEFAULT NULL, value_type enum_property_value_type NOT NULL, diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index afdfee71a..12c78f580 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -45,6 +45,37 @@ public function testStringPropertyPersistsAndReloads(): void $this->assertEquals('Environment', $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()) { From 482cd79cd1e7ea7405e3ed105e345b875a5ea456 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 20 Jul 2026 14:48:29 +0200 Subject: [PATCH 171/221] IcingaService: resolve legacy $config$ alias when rendering apply-for names Object names on array style apply-for rules never picked up the config to value alias, only custom variable values did. Old installations still have names referencing $config$ from before the loop variable was renamed, and those were rendering as a literal broken macro instead of the loop value. --- library/Director/Objects/IcingaService.php | 6 ++++- .../Director/Objects/IcingaServiceTest.php | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index 40d51d9fd..e9655b499 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -372,7 +372,11 @@ protected function renderObjectHeader() $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); diff --git a/test/php/library/Director/Objects/IcingaServiceTest.php b/test/php/library/Director/Objects/IcingaServiceTest.php index ac11224cb..05071d2f3 100644 --- a/test/php/library/Director/Objects/IcingaServiceTest.php +++ b/test/php/library/Director/Objects/IcingaServiceTest.php @@ -260,6 +260,28 @@ public function testApplyForConfigMacroStaysBackwardCompatibleWithValue() ); } + 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( From 05ca6bcf7a10474c11b577ecf612e8af521268be Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 08:59:22 +0200 Subject: [PATCH 172/221] Stop rewriting untouched siblings in fixed arrays and dictionaries Editing one slot in a fixed array or fixed dictionary used to force the whole container to be saved, even for children nobody touched. That turned inherited values into locally defaulted copies and bounced cleared fields back to their type default just because they were blank again. Each item now tracks whether its own value actually changed, a container only materializes when something really did, and fixed dictionaries get the same default fallback fixed arrays already had. --- application/forms/CustomVariablesForm.php | 8 +- .../forms/DictionaryElements/Dictionary.php | 16 ++ .../DictionaryElements/DictionaryItem.php | 62 ++++- .../DictionaryElements/DictionaryItemTest.php | 251 ++++++++++++++++++ 4 files changed, 327 insertions(+), 10 deletions(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index fac168514..0b2ec10e7 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -391,7 +391,8 @@ protected function onSuccess(): void continue; } - $value = $values[$key] ?? null; + $hasSubmittedValue = array_key_exists($key, $values); + $value = $hasSubmittedValue ? $values[$key] : null; if (is_array($value) && ! empty($value)) { if ($property['value_type'] === 'dynamic-dictionary') { @@ -422,6 +423,11 @@ protected function onSuccess(): void ); } + if (! $hasSubmittedValue) { + // Fully inherited and untouched, leave the object's own vars alone. + continue; + } + if (self::isValueUnset($value)) { if ($isOverrideServiceVars) { if (isset($overrideVars[$key])) { diff --git a/application/forms/DictionaryElements/Dictionary.php b/application/forms/DictionaryElements/Dictionary.php index 624876242..e7b44f778 100644 --- a/application/forms/DictionaryElements/Dictionary.php +++ b/application/forms/DictionaryElements/Dictionary.php @@ -229,6 +229,22 @@ public function getItemCount(): int 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->isUnchanged()) { + return false; + } + } + + return true; + } + /** * Get the dictionary value * diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index 575735edc..5c66c2129 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -501,6 +501,39 @@ public function setRemoveButton(?FormElement $removeButton): static 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 * @@ -511,12 +544,23 @@ public function getItem(): array $values = ['name' => $this->getElement('name')->getValue()]; $itemValue = $this->getElement('var'); if ($itemValue instanceof NestedDictionary or $itemValue instanceof Dictionary) { - $values['value'] = $itemValue->getDictionary(); - - if ($this->getElement('type')->getValue() === 'fixed-array') { - $value = $values['value']; - ksort($value); - $values['value'] = array_values($value); + // 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(); + + 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' @@ -532,9 +576,9 @@ public function getItem(): array } else { $defaultValue = null; - // Use the default value for fixed-array items only if the fixed array does not have an - // inherited value. - if ($parentType === 'fixed-array') { + // 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 (($parentType === 'fixed-array' || $parentType === 'fixed-dictionary') && $this->isUnchanged()) { match ($type) { 'string', 'sensitive' => $defaultValue = '', 'number' => $defaultValue = 0, diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index 9a7810afd..c4aa092ca 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -239,6 +239,201 @@ public function testGetItemPreservesExistingSensitiveValueOfZeroWhenLeftUnchange $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 testNestedFixedArrayInsideFixedArrayKeepsItsOwnValueWhenASiblingChanges(): void + { + if ($this->skipForMissingDb()) { + return; + } + + // A fixed array can hold another fixed array as one of its slots. Changing an + // unrelated sibling still saves the whole outer array, so the untouched nested + // array must contribute its own value instead of vanishing from it. + $db = $this->getDb(); + $outerUuidBytes = Uuid::uuid4()->getBytes(); + $keyName = self::PREFIX . 'monitoring_targets'; + $this->createdKeyNames[] = $keyName; + + DirectorProperty::create([ + 'uuid' => $outerUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-array', + 'label' => 'Monitoring Targets', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '0', + 'parent_uuid' => $outerUuidBytes, + 'value_type' => 'string', + ], $db)->store(); + + $nestedUuidBytes = Uuid::uuid4()->getBytes(); + DirectorProperty::create([ + 'uuid' => $nestedUuidBytes, + 'key_name' => '1', + 'parent_uuid' => $outerUuidBytes, + 'value_type' => 'fixed-array', + ], $db)->store(); + + foreach (['0', '1'] as $nestedPosition) { + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $nestedPosition, + 'parent_uuid' => $nestedUuidBytes, + 'value_type' => 'string', + ], $db)->store(); + } + + $property = [ + 'uuid' => $outerUuidBytes, + 'key_name' => $keyName, + 'value_type' => 'fixed-array', + 'label' => 'Monitoring Targets', + ]; + + $dictionaryItem = new DictionaryItem('0', $property); + $dictionaryItem->populate(DictionaryItem::prepare($property)); + $dictionaryItem->ensureAssembled(); + + $this->findNestedItem($dictionaryItem, '0')->getElement('var')->setValue('primary.example.com'); + + $result = $dictionaryItem->getItem(); + + $this->assertSame(['primary.example.com', ['', '']], $result['value']); + } + public function testMergeChildValuesAttachesMatchingValueByKeyName(): void { $propertyItems = [ @@ -592,6 +787,62 @@ private function buildSnmpV3DictionaryItem(): DictionaryItem return $dictionaryItem; } + /** + * 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): 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', + ]; + + 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. From 2841c6383274f90d3c35541f3fb31d97dfb14387 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 08:59:27 +0200 Subject: [PATCH 173/221] Fix apply-for name matching for dictionary valued host vars The Icingadb renderer matched generated service names by concatenating each element of the host var used in the apply-for rule, which works for an array but breaks for a dictionary whose values are themselves arrays or objects. Icinga 2 suffixes generated names with the value for a plain array loop and with the key for a dictionary loop, so the lookup now branches on the var type and uses keys for dictionaries, matching what IcingaService already does when rendering apply-for. --- .../Icingadb/CustomVarRenderer.php | 53 ++++++++++++++----- .../Icingadb/CustomVarRendererTest.php | 30 +++++++++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index f662e5328..c6ce41f6a 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -5,7 +5,8 @@ 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; @@ -91,6 +92,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 { @@ -146,19 +179,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) { diff --git a/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php b/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php index bb3b3f227..b7dbf8e8f 100644 --- a/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php +++ b/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php @@ -3,6 +3,7 @@ namespace Tests\Icinga\Module\Director\ProvidedHook\Icingadb; use Icinga\Application\Config; +use Icinga\Module\Director\CustomVariable\CustomVariable; use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\Objects\DirectorDatafieldCategory; use Icinga\Module\Director\Objects\DirectorProperty; @@ -117,6 +118,35 @@ public function testSameNamedChildrenInDifferentDictionariesDoNotShareVisibility ); } + 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; From c9cb90a541f85fe71833f74a0164c7aa7b1256d1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 08:59:32 +0200 Subject: [PATCH 174/221] Delete target-only child properties on basket restore A basket restore only ever stored or updated the children the snapshot brought with it, so a field added directly on the target after the snapshot was taken stuck around forever, restore never noticed it and never removed it. restoreCustomPropertyItems() now re-queries a property's actual children straight from the database and deletes any child that isn't among the ones the snapshot just restored. --- .../BasketSnapshotCustomVariableResolver.php | 14 ++++++ library/Director/Objects/DirectorProperty.php | 25 +++++++++++ .../BasketSnapshotCustomVariableTest.php | 43 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php index 369105e82..9587aa47b 100644 --- a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php +++ b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php @@ -325,7 +325,14 @@ protected function calculateUuidMap(): void 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; @@ -336,6 +343,13 @@ private function restoreCustomPropertyItems(DirectorProperty $property): bool } } + foreach ($property->fetchExistingChildrenFromDb() as $existingChild) { + if (! isset($keep[$existingChild->get('uuid')])) { + $existingChild->delete(); + $modified = true; + } + } + return $modified; } } diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index 77a435fbc..fbc7d9cff 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -238,6 +238,31 @@ public function fetchItemsFromDb(): array 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) { diff --git a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php index 9f49e3161..4e268b3db 100644 --- a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php +++ b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php @@ -201,6 +201,49 @@ public function testRestoreChildItemsForDictionary(): void ); } + 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 testRestoreIsIdempotent(): void { if ($this->skipForMissingDb()) { From 421d36a22f71616e6d973210755036e2252e1ee1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 09:11:11 +0200 Subject: [PATCH 175/221] RestApi: preserve required flag when a PUT relinks custom properties A PUT on the variables endpoint wipes and recreates every direct property attachment for the object, but the reattach only wrote the property and object UUIDs, so required properties silently came back as optional. Capture the required flag before the wipe and restore it on reattachment. --- .../RestApi/CustomVariableValueApplier.php | 53 +++++++++++++++- .../CustomVariableValueApplierTest.php | 60 ++++++++++++++++++- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/library/Director/RestApi/CustomVariableValueApplier.php b/library/Director/RestApi/CustomVariableValueApplier.php index ad9d48a9f..f3d3d313c 100644 --- a/library/Director/RestApi/CustomVariableValueApplier.php +++ b/library/Director/RestApi/CustomVariableValueApplier.php @@ -57,12 +57,19 @@ public function apply(CustomVarApplyRequest $request): void $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 @@ -90,7 +97,14 @@ public function apply(CustomVarApplyRequest $request): void $customProperties = $this->getObjectCustomProperties($object); foreach ($request->overRiddenCustomVars as $key => $value) { - $this->applySingleVar($request, $objectVars, $customProperties, $key, $value); + $this->applySingleVar( + $request, + $objectVars, + $customProperties, + $key, + $value, + $preservedRequiredFlags + ); } $objectVars->storeToDb($object); @@ -120,7 +134,8 @@ private function applySingleVar( CustomVariables $objectVars, array $customProperties, string $key, - mixed $value + mixed $value, + array $preservedRequiredFlags = [] ): void { $object = $request->object; $objectVars->set($key, $value); @@ -195,12 +210,44 @@ private function applySingleVar( $type = $object->getShortTableName(); $dbAdapter->insert('icinga_' . $type . '_property', [ 'property_uuid' => DbUtil::quoteBinaryCompat($customPropertyUuid, $dbAdapter), - $type . '_uuid' => DbUtil::quoteBinaryCompat($object->get('uuid'), $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 diff --git a/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php b/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php index e89cc3d5e..2d0cf2ca7 100644 --- a/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php +++ b/test/php/library/Director/RestApi/CustomVariableValueApplierTest.php @@ -249,6 +249,45 @@ public function testBaseObjectPutKeepsPropertyAttachmentsIntact(): void ); } + 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)) { @@ -264,7 +303,7 @@ private function createTemplate($db): IcingaHost return $host; } - private function attachProperties(IcingaHost $host, $db): void + private function attachProperties(IcingaHost $host, $db, array $requiredKeys = []): void { $dba = $db->getDbAdapter(); @@ -284,14 +323,31 @@ private function attachProperties(IcingaHost $host, $db): void ], $db); $dictProperty->store(); - foreach ([$stringProperty, $dictProperty] as $property) { + 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()) { From 88c92e1c8675a6e1d1673ccd66d8e5f3b21a300c Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 09:11:20 +0200 Subject: [PATCH 176/221] Accept array valued datalists in the REST validator Only fixed and dynamic arrays were classified as lists, so a datalist whose item type is dynamic-array fell through to the scalar default case and got rejected outright, even though the REST docs document exactly this as a valid array variable. The strict datalist check also compared the whole submitted array against a single entry name instead of checking each element, so it would reject a legitimate array of known values. assertMatchesType() now treats a datalist as either a single value or a list, and assertDatalistValueAllowed() checks every submitted value against the allowed entries. --- .../CustomVariableValueValidator.php | 32 +++++++--- .../CustomVariableValueValidatorTest.php | 64 +++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/library/Director/CustomVariable/CustomVariableValueValidator.php b/library/Director/CustomVariable/CustomVariableValueValidator.php index b83c777fd..0f498dabd 100644 --- a/library/Director/CustomVariable/CustomVariableValueValidator.php +++ b/library/Director/CustomVariable/CustomVariableValueValidator.php @@ -52,6 +52,19 @@ public static function assertMatchesType(string $key, mixed $value, string $valu )); } + break; + case 'datalist-strict': + case 'datalist-non-strict': + // A datalist can be a single value or, when its item type is + // 'dynamic-array', a list of values. + if (($value instanceof stdClass) || (is_array($value) && ! array_is_list($value))) { + throw new InvalidArgumentException(sprintf( + "The custom variable '%s' expects a single value or a list of values, got %s", + $key, + get_debug_type($value) + )); + } + break; default: if (is_array($value) || $value instanceof stdClass) { @@ -93,16 +106,19 @@ public static function assertDatalistValueAllowed( return; } + $allowedNames = []; foreach ($datalist->getEntries() as $entry) { - if ($entry->get('entry_name') === $value) { - return; - } + $allowedNames[] = $entry->get('entry_name'); } - throw new InvalidArgumentException(sprintf( - "'%s' is not a valid value for the custom variable '%s'", - (string) $value, - $key - )); + 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/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php b/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php index 6ffa265fc..52af0e73f 100644 --- a/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php +++ b/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php @@ -49,6 +49,24 @@ public function testDictionaryValueAcceptsObject(): void $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()) { @@ -95,6 +113,52 @@ public function testDatalistStrictAcceptsKnownValue(): void $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 */ From 9a3f56cebe67696a2435bb445946996b51ce71c2 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 09:33:27 +0200 Subject: [PATCH 177/221] Make datafield migration and delete atomic together Migrate committed its own transaction before --delete ran its cleanup in a separate unwrapped batch of deletes. A failure during cleanup could leave migrated properties sitting next to a half deleted legacy datafield with no clean way to retry. Fold the delete into the same transaction as the migration so either both happen or neither does. --- application/clicommands/MigrateCommand.php | 26 +++++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php index cb85e7cdc..3fec1f993 100644 --- a/application/clicommands/MigrateCommand.php +++ b/application/clicommands/MigrateCommand.php @@ -119,7 +119,7 @@ public function datafieldsAction() } if (! empty($customPropertiesToMigrate)) { - $this->migrateDatafields($customPropertiesToMigrate, $dryRun); + $this->migrateDatafields($customPropertiesToMigrate, $dryRun, $delete && ! $dryRun); } echo "Migration completed\n"; @@ -133,10 +133,6 @@ public function datafieldsAction() $totalSkipped = count(DirectorDatafield::loadAll($db)) - $totalMigrated; if ($delete) { - if (! $dryRun) { - $this->deleteMigratedDataFields(); - } - echo "The following datafields have been migrated and deleted:\n"; if ($this->isVerbose) { foreach ($this->migratedDataFields as $dataField) { @@ -252,11 +248,16 @@ private function prepareCustomProperties(): array /** * 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): void + private function migrateDatafields(array $customProperties, bool $dryRun, bool $delete = false): void { $db = $this->db(); @@ -324,6 +325,15 @@ private function migrateDatafields(array $customProperties, bool $dryRun): void if ($dryRun) { $migrate(); + + return; + } + + if ($delete) { + $db->runFailSafeTransaction(function () use ($migrate) { + $migrate(); + $this->deleteMigratedDataFields(); + }); } else { $db->runFailSafeTransaction($migrate); } @@ -564,6 +574,10 @@ private function migrateDatafieldObjectTemplateBinding( /** * 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 From cbece00b37d5c25229413ee4146265dc23bfed44 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 09:33:31 +0200 Subject: [PATCH 178/221] Wrap custom variable form writes in a transaction Property attachments, removals and the final vars store were all separate writes with nothing tying them together. A failure partway through could attach a property without storing its value, or remove a var without detaching its property. Move the write logic into its own method and run it inside runFailSafeTransaction. --- application/forms/CustomVariablesForm.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index 0b2ec10e7..6e3d086ab 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -362,6 +362,21 @@ 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 */ From 39d21ef774cf91745429ef9a3bb7c822008e2404 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 10:17:59 +0200 Subject: [PATCH 179/221] Scope dictionary and array sensitivity metadata to the object's own property tree Dictionary child config and sensitive-array-item positions were keyed only by the immediate parent's bare name and loaded by sweeping the entire director_property table. Two unrelated properties sharing a nested name, like two different dictionaries each having a "targets" fixed-array or a "credentials" dictionary, could overwrite each other's masking, letting a sensitive value render in the clear. Metadata is now scoped by each property's full ancestor chain and loaded only from the rendered object's own attached properties. --- .../Icingadb/CustomVarRenderer.php | 197 ++++++++++-------- .../Icingadb/CustomVarRendererTest.php | 46 ++++ .../Icingadb/TestableCustomVarRenderer.php | 13 +- 3 files changed, 168 insertions(+), 88 deletions(-) diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index c6ce41f6a..6a3501b8d 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -49,19 +49,18 @@ class CustomVarRenderer extends CustomVarRendererHook protected $customPropertyDictionaries = []; /** - * Dictionary child configuration, keyed by parent property key_name and then by - * child key_name. Child key_names are only unique within their own parent - * dictionary, so this must never be flattened into $customVariableConfig - two - * different dictionaries may each have an identically-named child with different - * sensitivity. + * 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 = []; /** - * Positions of sensitive items within fixed-/dynamic-array custom properties, keyed by - * the array's own key_name and then by item position, e.g. ['ssh_args' => ['3' => true]] + * 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 */ @@ -303,7 +302,6 @@ public function prefetchForObject(Model $object): bool return true; } - $customPropertiesWithDatalists = []; foreach ($customProperties as $customProperty) { $propertyName = $customProperty['key_name']; $this->customVariableConfig[$propertyName] = ['label' => $customProperty['label']]; @@ -315,73 +313,12 @@ public function prefetchForObject(Model $object): bool $this->customVariableConfig[$propertyName]['visibility'] = 'hidden'; } - if (str_starts_with($customProperty['value_type'], 'datalist-')) { - $customPropertiesWithDatalists[$customProperty['uuid']] = $customProperty; - } elseif (str_ends_with($customProperty['value_type'], '-dictionary')) { - $this->dictionaryNames[] = $customProperty['key_name']; + if (str_ends_with($customProperty['value_type'], '-dictionary')) { + $this->dictionaryNames[] = $propertyName; } } - $dictionaryItems = $db->select()->from( - ['dpp' => 'director_property'], - [] - ) - ->join(['dpc' => 'director_property'], 'dpp.uuid = dpc.parent_uuid', []) - ->columns([ - 'parent_name' => 'dpp.key_name', - 'key_name' => 'dpc.key_name', - 'label' => 'dpc.label', - 'value_type' => 'dpc.value_type', - 'uuid' => 'dpc.uuid' - ])->where('dpp.value_type', ['fixed-dictionary', 'dynamic-dictionary']); - - foreach ($dictionaryItems as $dictionaryItem) { - $propertyName = $dictionaryItem->key_name; - - $this->customPropertyDictionaries[$dictionaryItem->parent_name][$propertyName] - = $dictionaryItem->label; - if (is_string($propertyName)) { - // Scoped by parent dictionary: two different dictionaries may have a - // child with the same key_name (e.g. both a "password"), and only one - // of them may be sensitive. Keying this globally by the bare child - // name would let a later, non-sensitive dictionary's child silently - // unmask an earlier sensitive one. - $childConfig = ['label' => $dictionaryItem->label]; - if ($dictionaryItem->value_type === 'sensitive') { - $childConfig['visibility'] = 'hidden'; - } - - $this->dictionaryChildConfig[$dictionaryItem->parent_name][$propertyName] = $childConfig; - } - - if (str_starts_with($dictionaryItem->value_type, 'datalist-')) { - $customPropertiesWithDatalists[$dictionaryItem->uuid] = $dictionaryItem; - } - } - - // Unlike dynamic-array (one item_type for all elements) and datalist item types, - // fixed-array items are individually typed, so a single fixed-array can carry a - // 'sensitive' item at one position and plain strings/numbers at others. Track - // which positions are sensitive, scoped by the array's own key_name, so two - // different fixed-arrays sharing the same positional key_names (e.g. both having - // a "0", "1", ...) don't get mixed up. - $sensitiveArrayItems = $db->select()->from( - ['dpp' => 'director_property'], - [] - ) - ->join(['dpc' => 'director_property'], 'dpp.uuid = dpc.parent_uuid', []) - ->columns([ - 'parent_name' => 'dpp.key_name', - 'key_name' => 'dpc.key_name', - ]) - ->where('dpp.value_type', 'fixed-array') - ->where('dpc.value_type', 'sensitive'); - - foreach ($sensitiveArrayItems as $sensitiveArrayItem) { - if (is_string($sensitiveArrayItem->parent_name)) { - $this->sensitiveArrayItems[$sensitiveArrayItem->parent_name][$sensitiveArrayItem->key_name] = true; - } - } + $this->loadNestedPropertyConfig($db, $customProperties); $dataListEntries = $db->select()->from( ['dpd' => 'director_property_datalist'], @@ -504,28 +441,112 @@ protected function getObjectCustomProperties(IcingaObject $object): array } /** - * Look up dictionary-child-scoped configuration for $key under its immediate - * parent dictionary/array $parentKey, if any + * 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): ?array + private function dictionaryChildConfigFor(string $key, ?string $parentKey, ?string $grandparentKey = null): ?array { - if ($parentKey !== null && isset($this->dictionaryChildConfig[$parentKey][$key])) { - return $this->dictionaryChildConfig[$parentKey][$key]; + if ($parentKey === null) { + return null; } - return null; + return $this->dictionaryChildConfig[$this->scopeKey($grandparentKey, $parentKey)][$key] ?? null; } - public function renderCustomVarKey(string $key, ?string $parentKey = null) + public function renderCustomVarKey(string $key, ?string $parentKey = null, ?string $grandparentKey = null) { try { $label = $this->fieldConfig[$key]['label'] - ?? $this->dictionaryChildConfigFor($key, $parentKey)['label'] + ?? $this->dictionaryChildConfigFor($key, $parentKey, $grandparentKey)['label'] ?? $this->customVariableConfig[$key]['label'] ?? null; if ($label === null) { @@ -544,9 +565,12 @@ public function renderCustomVarKey(string $key, ?string $parentKey = null) return null; } - public function renderCustomVarValue(string $key, $value, ?string $parentKey = null) - { - $childConfig = $this->dictionaryChildConfigFor($key, $parentKey); + 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; @@ -564,8 +588,9 @@ public function renderCustomVarValue(string $key, $value, ?string $parentKey = n if (is_array($value) && ! isset($this->customPropertyDictionaries[$key])) { $renderedValue = []; + $arrayScope = $this->scopeKey($parentKey, $key); foreach ($value as $k => $v) { - if (isset($this->sensitiveArrayItems[$key][$k])) { + if (isset($this->sensitiveArrayItems[$arrayScope][$k])) { $renderedValue[$k] = '***'; } elseif (is_string($v) && isset($this->datalistMaps[$key][$v])) { $renderedValue[$k] = new HtmlElement( @@ -673,8 +698,8 @@ protected function renderDictionaryVal(string $key, array $value): ?ValidHtml $this->dictionaryLevel++; foreach ($val as $childKey => $childVal) { - $childVal = $this->renderCustomVarValue($childKey, $childVal, $k) ?? $childVal; - $label = $this->renderCustomVarKey($childKey, $k) ?? $childKey; + $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) diff --git a/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php b/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php index b7dbf8e8f..69572c47e 100644 --- a/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php +++ b/test/php/library/Director/ProvidedHook/Icingadb/CustomVarRendererTest.php @@ -118,6 +118,52 @@ public function testSameNamedChildrenInDifferentDictionariesDoNotShareVisibility ); } + 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(); diff --git a/test/php/library/Director/ProvidedHook/Icingadb/TestableCustomVarRenderer.php b/test/php/library/Director/ProvidedHook/Icingadb/TestableCustomVarRenderer.php index 95b10241a..77b5da1b0 100644 --- a/test/php/library/Director/ProvidedHook/Icingadb/TestableCustomVarRenderer.php +++ b/test/php/library/Director/ProvidedHook/Icingadb/TestableCustomVarRenderer.php @@ -10,9 +10,18 @@ */ class TestableCustomVarRenderer extends CustomVarRenderer { - public function seedDictionaryChild(string $parentKey, string $childKey, array $config): void + public function seedDictionaryChild( + string $parentKey, + string $childKey, + array $config, + ?string $grandparentKey = null + ): void { + $this->dictionaryChildConfig[$this->scopeKey($grandparentKey, $parentKey)][$childKey] = $config; + } + + public function seedSensitiveArrayItem(string $parentKey, string $childKey, ?string $grandparentKey = null): void { - $this->dictionaryChildConfig[$parentKey][$childKey] = $config; + $this->sensitiveArrayItems[$this->scopeKey($grandparentKey, $parentKey)][$childKey] = true; } public function seedDictionaryName(string $key): void From 1da2f963d05858814b0047c6a2da40a5897bdbf8 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 11:57:33 +0200 Subject: [PATCH 180/221] DirectorProperty: Reject 'sensitive' as dynamic-array child value_type --- library/Director/Objects/DirectorProperty.php | 26 ++++++++++++++----- .../Director/Objects/DirectorPropertyTest.php | 23 ++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index fbc7d9cff..8e9bc4e5e 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -436,20 +436,34 @@ public static function import(stdClass $plain, Db $db): static /** * @throws InvalidArgumentException if a nested property is being stored with a value_type - * that may only be used at the top level + * that may only be used at the top level, or a 'sensitive' + * item type is used under a dynamic-array */ protected function beforeStore(): void { - if ( - $this->get('parent_uuid') !== null - && in_array($this->get('value_type'), self::NON_NESTABLE_TYPES, true) - ) { + $parentUuid = $this->get('parent_uuid'); + if ($parentUuid === null) { + return; + } + + $valueType = $this->get('value_type'); + + 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 another dynamic-dictionary", - $this->get('value_type') + $valueType )); } + + // A dynamic-array shows every entry in the clear, so a sensitive item here + // could never actually be hidden. + if ($valueType === 'sensitive') { + $parent = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $this->connection); + if ($parent !== null && $parent->get('value_type') === 'dynamic-array') { + throw new InvalidArgumentException("'sensitive' cannot be used as the item type of a dynamic-array"); + } + } } protected function onStore(): void diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 12c78f580..621758435 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -173,6 +173,29 @@ public function testDynamicDictionaryNestingIsRejectedByTheModel(): void $child->store(); } + 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' => 'item', + 'parent_uuid' => $parent->get('uuid'), + 'value_type' => 'sensitive', + ], $db); + + $this->expectException(InvalidArgumentException::class); + $child->store(); + } + public function testDatalistStrictAssociatesDatalist(): void { if ($this->skipForMissingDb()) { From 66c131f4c9152162726a165179f0045a73e98997 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 14:38:41 +0200 Subject: [PATCH 181/221] Add required flag for custom variables Templates can now mark a custom variable required when attaching it to an object, and objects must fill in a value before saving their custom variables tab. Templates stay optional themselves, and the flag can be flipped later right on the template without detaching and reattaching the variable. --- application/forms/CustomVariablesForm.php | 98 +++++++- .../forms/DictionaryElements/Dictionary.php | 25 ++ .../DictionaryElements/DictionaryItem.php | 47 ++++ application/forms/ObjectCustomvarForm.php | 17 ++ .../Web/Controller/ObjectController.php | 80 +++++- public/css/custom-variables-form.less | 7 +- .../DictionaryElements/DictionaryItemTest.php | 232 ++++++++++++++++++ 7 files changed, 490 insertions(+), 16 deletions(-) diff --git a/application/forms/CustomVariablesForm.php b/application/forms/CustomVariablesForm.php index 6e3d086ab..998fbb4de 100644 --- a/application/forms/CustomVariablesForm.php +++ b/application/forms/CustomVariablesForm.php @@ -45,6 +45,9 @@ class CustomVariablesForm extends CompatForm /** @var array UUIDs of custom variables that have been added */ private array $addedVarUuids = []; + /** @var array UUIDs of custom variables that have been marked required */ + private array $requiredVarUuids = []; + public function __construct( public readonly IcingaObject $object, protected array $objectProperties = [] @@ -76,12 +79,39 @@ public function setAddedVarUuids(array $uuids): static 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', - $this->objectProperties, + $properties, ['class' => 'no-border'] ))->setAllowItemRemoval($this->object->isTemplate()); @@ -107,6 +137,22 @@ protected function assemble(): void $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()) { @@ -114,6 +160,7 @@ protected function assemble(): void } $this->addHtml($addedUuidsContainer); + $this->addHtml($requiredUuidsContainer); $this->registerElement($saveButton); $removedItems = $dictionary->getItemsToRemove(); @@ -258,6 +305,12 @@ public function prepareNewPropertyRow(array $propertyData, int $index): BaseHtml /** @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', @@ -342,6 +395,26 @@ private function assertCanAttachNewVariable(): void } } + /** + * 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 * @@ -382,6 +455,7 @@ private function persistPropertyChanges(): void /** @var Dictionary $propertiesElement */ $propertiesElement = $this->getElement('properties'); $values = $propertiesElement->getDictionary(); + $requiredFlags = $propertiesElement->getRequiredFlags(); $itemsToRemove = $propertiesElement->getItemsToRemove(); $type = $this->object->getShortTableName(); $db = $this->object->getDb(); @@ -433,9 +507,29 @@ private function persistPropertyChanges(): void "icinga_$type" . '_property', [ $type . '_uuid' => DbUtil::quoteBinaryCompat($this->object->uuid, $db), - 'property_uuid' => DbUtil::quoteBinaryCompat($propertyUuid->getBytes(), $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) { diff --git a/application/forms/DictionaryElements/Dictionary.php b/application/forms/DictionaryElements/Dictionary.php index e7b44f778..5dd0d1930 100644 --- a/application/forms/DictionaryElements/Dictionary.php +++ b/application/forms/DictionaryElements/Dictionary.php @@ -266,4 +266,29 @@ public function getDictionary(): array return $items; } + + /** + * 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 index 5c66c2129..ac9de7310 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -5,6 +5,7 @@ use Icinga\Application\Config; use Icinga\Module\Director\Db; use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Forms\CustomVariablesForm; use Icinga\Module\Director\Forms\Validator\DatalistEntryValidator; use Icinga\Module\Director\Web\Form\Element\ArrayElement; use Icinga\Module\Director\Web\Form\Element\IplBoolean; @@ -13,6 +14,7 @@ use ipl\Html\Contract\FormElement; use ipl\Html\FormElement\FieldsetElement; use ipl\Html\HtmlElement; +use ipl\Validator\CallbackValidator; use ipl\Web\FormElement\TermInput; use ipl\Web\Url; use PDO; @@ -105,6 +107,11 @@ protected function assemble(): void if ($this->removeButton !== null) { $this->addAttributes(['class' => ['removable']]); + $this->addElement('checkbox', 'item_required', [ + 'label' => $this->translate('Required'), + 'class' => 'item-required-checkbox', + 'value' => ($this->fields['required_current'] ?? false) ? 'y' : 'n' + ]); $this->addHtml(new HtmlElement( 'div', null, @@ -272,6 +279,42 @@ protected function assemble(): void ] ); } + + if ($this->fields['required'] ?? false) { + $this->markValueRequired($this->getElement($valElementName)); + } + } + + /** + * 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) { + if (! CustomVariablesForm::isValueUnset(CustomVariablesForm::filterEmpty($element->getDictionary()))) { + return true; + } + + $validator->addMessage($this->translate('This field is required.')); + + return false; + }) + ]); } public function populate($values) @@ -613,6 +656,10 @@ public function getItem(): array $values['delete'] = $this->getElement($markForRemovalElement)->getValue(); } + if ($this->hasElement('item_required')) { + $values['required'] = $this->getElement('item_required')->getValue() === 'y'; + } + return $values; } } diff --git a/application/forms/ObjectCustomvarForm.php b/application/forms/ObjectCustomvarForm.php index 9b2985084..8b3cdfe7b 100644 --- a/application/forms/ObjectCustomvarForm.php +++ b/application/forms/ObjectCustomvarForm.php @@ -38,6 +38,16 @@ public function getPropertyName(): string 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()); @@ -59,6 +69,13 @@ protected function assemble(): void $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') ]); diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index afa1a0067..f394e0e19 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -352,6 +352,7 @@ public function addVarAction(): void $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)) @@ -359,6 +360,7 @@ public function addVarAction(): void ->on(ObjectCustomvarForm::ON_SUBMIT, function (ObjectCustomvarForm $form) use ( $object, $addedVarUuids, + $requiredVarUuids, $nextSlotIndex ) { $newUuid = $form->getValue('property'); @@ -366,6 +368,10 @@ public function addVarAction(): void $addedVarUuids[] = $newUuid; } + if ($form->isRequired() && ! in_array($newUuid, $requiredVarUuids, true)) { + $requiredVarUuids[] = $newUuid; + } + $redirectUrl = Url::fromPath( 'director/' . $this->getType() . '/variables', [ @@ -375,6 +381,7 @@ public function addVarAction(): void ] ); $redirectUrl->getParams()->addValues('addedVarUuids', $addedVarUuids); + $redirectUrl->getParams()->addValues('requiredVarUuids', $requiredVarUuids); $this->redirectNow($redirectUrl); }) @@ -422,7 +429,12 @@ public function variablesAction(): void array_filter(explode(',', $this->getRequest()->getPost('addedVarUuids', ''))) )); - $form = $this->prepareCustomPropertiesForm($object, null, $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( @@ -439,7 +451,9 @@ function (CustomVariablesForm $form) { Notification::success($this->translate('There is nothing to change.')); } - $this->redirectNow(Url::fromRequest()->without(['addedVarUuids', 'newVarUuid', 'nextSlotIndex'])); + $this->redirectNow(Url::fromRequest()->without( + ['addedVarUuids', 'requiredVarUuids', 'newVarUuid', 'nextSlotIndex'] + )); } )->on( CustomVariablesForm::ON_REQUEST, @@ -450,14 +464,23 @@ function ( $object, $newVarUuid, $nextSlotIndex, - $addedVarUuids + $addedVarUuids, + $requiredVarUuids ) { if ($newVarUuid === null) { return; } - $this->sendNewVarMultipartUpdate($object, $form, $newVarUuid, $nextSlotIndex, $addedVarUuids); + $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()); @@ -476,6 +499,7 @@ function ( ['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'], [ @@ -512,6 +536,7 @@ function ( * @param string $newVarUuid * @param int $nextSlotIndex * @param array $addedVarUuids + * @param array $requiredVarUuids * * @return void */ @@ -520,7 +545,8 @@ private function sendNewVarMultipartUpdate( CustomVariablesForm $form, string $newVarUuid, int $nextSlotIndex, - array $addedVarUuids + array $addedVarUuids, + array $requiredVarUuids = [] ): void { $type = $object->getShortTableName(); $db = $this->db()->getDbAdapter(); @@ -550,6 +576,7 @@ private function sendNewVarMultipartUpdate( 'label' => $row['label'], 'allow_removal' => true, 'new' => true, + 'required' => in_array($newVarUuid, $requiredVarUuids, true), $type . '_uuid' => $object->get('uuid') ]; @@ -611,6 +638,7 @@ private function sendNewVarMultipartUpdate( ); $buttonUrl->getParams()->addValues('addedVarUuids', $addedVarUuids); + $buttonUrl->getParams()->addValues('requiredVarUuids', $requiredVarUuids); $this->addPart( (new ButtonLink( @@ -622,7 +650,7 @@ private function sendNewVarMultipartUpdate( 'add-custom-var-button' ); - // Update hidden addedVarUuids inputs so POST form submission carries them + // Update hidden addedVarUuids/requiredVarUuids inputs so POST form submission carries them $addedUuidsContainer = new HtmlDocument(); $addedUuidsElement = $form->createElement( 'hidden', @@ -634,6 +662,18 @@ private function sendNewVarMultipartUpdate( $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'); } /** @@ -641,14 +681,16 @@ private function sendNewVarMultipartUpdate( * * @param IcingaObject $object * @param IcingaHost|null $host - * @param string[] $addedVarUuids UUID strings of properties added this session + * @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 $addedVarUuids = [], + array $requiredVarUuids = [] ): ?CustomVariablesForm { $isOverrideVars = $host !== null; if ($isOverrideVars) { @@ -662,10 +704,16 @@ public function prepareCustomPropertiesForm( $inheritedVars = json_decode(json_encode($object->getInheritedVars()), JSON_OBJECT_AS_ARRAY); $origins = $object->getOriginsVars(); - $objectProperties = $this->getObjectCustomProperties($object, $isOverrideVars, $addedVarUuids); + $objectProperties = $this->getObjectCustomProperties( + $object, + $isOverrideVars, + $addedVarUuids, + $requiredVarUuids + ); $form = (new CustomVariablesForm($object, $objectProperties)) ->setAction(Url::fromRequest()->getAbsoluteUrl()) - ->setAddedVarUuids($addedVarUuids); + ->setAddedVarUuids($addedVarUuids) + ->setRequiredVarUuids($requiredVarUuids); if (empty($objectProperties)) { return $form; } @@ -913,14 +961,16 @@ private function valueTypeOrderExpr(DbConnection $db, array $types): string /** * Get custom properties for the object, including session-added ones. * - * @param string[] $addedVarUuids UUID strings of properties added this session + * @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 $addedVarUuids = [], + array $requiredVarUuids = [] ): array { if ($object->uuid === null) { return []; @@ -950,6 +1000,7 @@ protected function getObjectCustomProperties( $type . '_uuid' => 'iop.' . $type . '_uuid', 'value_type' => 'dp.value_type', 'label' => 'dp.label', + 'required' => 'iop.required', 'children' => 'COUNT(cdp.uuid)' ] ) @@ -964,7 +1015,7 @@ protected function getObjectCustomProperties( [] ) ->where('iop.' . $type . '_uuid IN (?)', $uuids) - ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label', $type . '_uuid']) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label', $type . '_uuid', 'iop.required']) ->order($this->valueTypeOrderExpr($db, [ 'string', 'number', @@ -995,6 +1046,7 @@ protected function getObjectCustomProperties( $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']]; @@ -1043,6 +1095,8 @@ protected function getObjectCustomProperties( $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']]; } diff --git a/public/css/custom-variables-form.less b/public/css/custom-variables-form.less index de0ed59ec..55783b4e8 100644 --- a/public/css/custom-variables-form.less +++ b/public/css/custom-variables-form.less @@ -11,7 +11,7 @@ border-color: transparent; } - .added-var-uuids:focus { + .added-var-uuids:focus, .required-var-uuids:focus { outline: none; } @@ -78,6 +78,11 @@ 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; diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index c4aa092ca..ec143f5fb 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -434,6 +434,238 @@ public function testNestedFixedArrayInsideFixedArrayKeepsItsOwnValueWhenASibling $this->assertSame(['primary.example.com', ['', '']], $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 testRequiredToggleIsRenderedNextToTheRemoveButton(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItemWithRemoveButton(requiredCurrent: true); + + $this->assertTrue($item->hasElement('item_required')); + } + + public function testRequiredToggleIsSeededFromTheStoredRequiredFlag(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItemWithRemoveButton(requiredCurrent: true); + + $this->assertSame('y', $item->getElement('item_required')->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')); + } + + public function testGetItemReportsTheToggledRequiredFlag(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $item = $this->buildScalarDictionaryItemWithRemoveButton(requiredCurrent: true); + $item->getElement('item_required')->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 = [ From a55c51856fd8ef4ec20ee89706dd4e5ac3ac5ce7 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 14:39:34 +0200 Subject: [PATCH 182/221] Fix required check ignoring inherited values and fixed array defaults A required custom variable now counts as satisfied when its value is inherited from a parent template, instead of forcing a redundant local override. Also fixed a required fixed array or fixed dictionary silently passing validation when left completely blank, since an untouched number or bool child falls back to a storage default that read as a real value during the check. --- .../forms/DictionaryElements/Dictionary.php | 25 ++- .../DictionaryElements/DictionaryItem.php | 40 ++++- .../DictionaryElements/NestedDictionary.php | 6 +- .../NestedDictionaryItem.php | 6 +- .../DictionaryElements/DictionaryItemTest.php | 154 +++++++++++++++++- 5 files changed, 219 insertions(+), 12 deletions(-) diff --git a/application/forms/DictionaryElements/Dictionary.php b/application/forms/DictionaryElements/Dictionary.php index 5dd0d1930..06aeca838 100644 --- a/application/forms/DictionaryElements/Dictionary.php +++ b/application/forms/DictionaryElements/Dictionary.php @@ -248,16 +248,18 @@ public function allChildrenUnchanged(): bool /** * Get the dictionary value * + * @param bool $applyUnchangedDefaults Apply the untouched-child type defaults, see DictionaryItem::getItem() + * * @return array */ - public function getDictionary(): 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(); + $item = $element->ensureAssembled()->getItem($applyUnchangedDefaults); if (isset($item['name']) && array_key_exists('value', $item)) { $items[$item['name']] = $item['value']; } @@ -267,6 +269,25 @@ public function getDictionary(): array 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 * diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index ac9de7310..facd0d697 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -281,7 +281,17 @@ protected function assemble(): void } if ($this->fields['required'] ?? false) { - $this->markValueRequired($this->getElement($valElementName)); + $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); + } } } @@ -306,7 +316,10 @@ private function markValueRequired(FormElement $element): void $element->addValidators([ new CallbackValidator(function ($value, CallbackValidator $validator) use ($element) { - if (! CustomVariablesForm::isValueUnset(CustomVariablesForm::filterEmpty($element->getDictionary()))) { + // 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; } @@ -317,6 +330,16 @@ private function markValueRequired(FormElement $element): void ]); } + /** + * 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)) { @@ -580,9 +603,12 @@ public function isUnchanged(): bool /** * 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(): array + public function getItem(bool $applyUnchangedDefaults = true): array { $values = ['name' => $this->getElement('name')->getValue()]; $itemValue = $this->getElement('var'); @@ -597,7 +623,7 @@ public function getItem(): array && $itemValue->allChildrenUnchanged(); if (! $isUntouched) { - $values['value'] = $itemValue->getDictionary(); + $values['value'] = $itemValue->getDictionary($applyUnchangedDefaults); if ($this->getElement('type')->getValue() === 'fixed-array') { $value = $values['value']; @@ -621,7 +647,11 @@ public function getItem(): array // 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 (($parentType === 'fixed-array' || $parentType === 'fixed-dictionary') && $this->isUnchanged()) { + if ( + $applyUnchangedDefaults + && ($parentType === 'fixed-array' || $parentType === 'fixed-dictionary') + && $this->isUnchanged() + ) { match ($type) { 'string', 'sensitive' => $defaultValue = '', 'number' => $defaultValue = 0, diff --git a/application/forms/DictionaryElements/NestedDictionary.php b/application/forms/DictionaryElements/NestedDictionary.php index 5bc3e4f7c..58168cd44 100644 --- a/application/forms/DictionaryElements/NestedDictionary.php +++ b/application/forms/DictionaryElements/NestedDictionary.php @@ -204,15 +204,17 @@ public function populate($values) /** * Get the nested dictionary value * + * @param bool $applyUnchangedDefaults See DictionaryItem::getItem() + * * @return array */ - public function getDictionary(): array + public function getDictionary(bool $applyUnchangedDefaults = true): array { $values = []; $count = 0; foreach ($this->ensureAssembled()->getElements() as $element) { if ($element instanceof NestedDictionaryItem) { - $property = $element->getItem(); + $property = $element->getItem($applyUnchangedDefaults); if (! empty($property['key']) && array_key_exists('value', $property)) { $values[$property['key']] = $property['value']; } else { diff --git a/application/forms/DictionaryElements/NestedDictionaryItem.php b/application/forms/DictionaryElements/NestedDictionaryItem.php index 243db0ec3..02e3789b1 100644 --- a/application/forms/DictionaryElements/NestedDictionaryItem.php +++ b/application/forms/DictionaryElements/NestedDictionaryItem.php @@ -125,15 +125,17 @@ public static function prepare(array $nestedItems, array $property): array /** * Get the nested dictionary item value * + * @param bool $applyUnchangedDefaults See DictionaryItem::getItem() + * * @return NestedDictionaryItemDataType */ - public function getItem(): array + public function getItem(bool $applyUnchangedDefaults = true): array { $this->ensureAssembled(); $key = $this->getElement('key')->getValue(); $values = []; $values['key'] = $key; - $values['value'] = $this->getElement('var')->getDictionary(); + $values['value'] = $this->getElement('var')->getDictionary($applyUnchangedDefaults); return $values; } diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index ec143f5fb..5da3689a7 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -161,6 +161,20 @@ public function testGetItemUsesNumberDefaultForFixedArrayItemWithInheritedValueL $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, @@ -508,6 +522,71 @@ public function testRequiredFixedDictionaryPassesValidationWhenOneChildIsFilled( $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()) { @@ -1019,13 +1098,85 @@ private function buildSnmpV3DictionaryItem(): DictionaryItem 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): DictionaryItem + private function buildDiskMirrorsDictionaryItem(?array $localValue = null, bool $required = false): DictionaryItem { $db = $this->getDb(); $parentUuidBytes = Uuid::uuid4()->getBytes(); @@ -1053,6 +1204,7 @@ private function buildDiskMirrorsDictionaryItem(?array $localValue = null): Dict 'key_name' => $keyName, 'value_type' => 'fixed-array', 'label' => 'Disk Mirrors', + 'required' => $required, ]; if ($localValue !== null) { From 6138e88025cd5eebefe25402d916464c47cb73d8 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 15:24:11 +0200 Subject: [PATCH 183/221] CustomVarRenderer: Have one parameter per line in multline function definition --- library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php index 6a3501b8d..0b6a3c913 100644 --- a/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php +++ b/library/Director/ProvidedHook/Icingadb/CustomVarRenderer.php @@ -566,7 +566,8 @@ public function renderCustomVarKey(string $key, ?string $parentKey = null, ?stri } public function renderCustomVarValue( - string $key, $value, + string $key, + $value, ?string $parentKey = null, ?string $grandparentKey = null ): mixed { From ddf9830a92dd2d9b0aaae70860a22ca3865c3cca Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 15:37:08 +0200 Subject: [PATCH 184/221] Fix crash when checking unchanged fixed-array/dictionary children Child DictionaryItems are assembled lazily, so allChildrenUnchanged() was hitting isUnchanged() before the 'var' element even existed. --- application/forms/DictionaryElements/Dictionary.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/DictionaryElements/Dictionary.php b/application/forms/DictionaryElements/Dictionary.php index 06aeca838..48534d648 100644 --- a/application/forms/DictionaryElements/Dictionary.php +++ b/application/forms/DictionaryElements/Dictionary.php @@ -237,7 +237,7 @@ public function getItemCount(): int public function allChildrenUnchanged(): bool { foreach ($this->ensureAssembled()->getElements() as $element) { - if ($element instanceof DictionaryItem && ! $element->isUnchanged()) { + if ($element instanceof DictionaryItem && ! $element->ensureAssembled()->isUnchanged()) { return false; } } From 46ae7297c528224173b13738873c5bc6b2849416 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 16:07:55 +0200 Subject: [PATCH 185/221] Document required custom variables and fix stale doc claims The required flag feature had no documentation at all, so add a section covering how it works on templates versus objects, inherited values, and the fixed-array empty-value fix. Also correct the claim that service sets get a Custom Variables tab, since that never shipped. Note the current scope of sensitive masking, the basket limitation around datalist entries, the migration backfill gap, and that PUT now preserves the required flag on relink. Cleaned up the trailing whitespace in the REST doc while touching that section. --- doc/12-Handling-custom-variables.md | 46 ++++++++++++++++++++++++++--- doc/70-REST-API.md | 16 ++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/doc/12-Handling-custom-variables.md b/doc/12-Handling-custom-variables.md index e98eae282..f76f2a142 100644 --- a/doc/12-Handling-custom-variables.md +++ b/doc/12-Handling-custom-variables.md @@ -137,6 +137,12 @@ vars.snmp_community = "s3cr3t-community" 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. @@ -279,10 +285,10 @@ add the nested items: fixed positions for `fixed-array`, fixed keys for ### Attaching custom variables to objects and templates Every object type that supports custom variables (host, service, command, -user, notification, and service sets) exposes a `Custom Variables` tab on -its object and template detail pages, next to the `Fields (Deprecated)` -tab. Use `Add Custom Variable` there to attach a configured property and -fill in its value: +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. @@ -291,6 +297,28 @@ fill in its value: 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 --------------- @@ -333,6 +361,11 @@ 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 --------------------- @@ -341,6 +374,11 @@ 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 diff --git a/doc/70-REST-API.md b/doc/70-REST-API.md index ebec20b8d..f023295f2 100644 --- a/doc/70-REST-API.md +++ b/doc/70-REST-API.md @@ -257,10 +257,17 @@ 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 +* `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 @@ -322,6 +329,11 @@ stored. Keep that in mind when logging requests or sharing API responses. } ``` +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 From 212880c765734a027c5dcd188173f13903d8f42f Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 16:48:41 +0200 Subject: [PATCH 186/221] DictionaryItem: Clarify that the required flag only applies to inheriting objects, not the template itself --- application/forms/DictionaryElements/DictionaryItem.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index facd0d697..a8cd3114b 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -108,7 +108,7 @@ protected function assemble(): void if ($this->removeButton !== null) { $this->addAttributes(['class' => ['removable']]); $this->addElement('checkbox', 'item_required', [ - 'label' => $this->translate('Required'), + 'label' => $this->translate('Required in inheriting objects'), 'class' => 'item-required-checkbox', 'value' => ($this->fields['required_current'] ?? false) ? 'y' : 'n' ]); From 467a78b388156888b1344fe088f6d9b1bc20c212 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 21 Jul 2026 17:03:54 +0200 Subject: [PATCH 187/221] Restrict required-checkbox selector to direct child The descendant selector was also matching nested control groups that happen to contain an item-required-checkbox further down, throwing off their alignment. --- public/css/custom-variables-form.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/css/custom-variables-form.less b/public/css/custom-variables-form.less index 55783b4e8..285776201 100644 --- a/public/css/custom-variables-form.less +++ b/public/css/custom-variables-form.less @@ -78,7 +78,7 @@ justify-content: space-between; } - .control-group:has(.item-required-checkbox) { + .control-group:has(> .item-required-checkbox) { justify-content: end; margin: 1em 0.5em 0 0; } From 5d48f06c3d49308fe8fc1895caf11cb00ebc6d38 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 22 Jul 2026 09:37:18 +0200 Subject: [PATCH 188/221] ObjectImporter: Unset the left over 'customVariables' key for the services in the set --- library/Director/Data/ObjectImporter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/library/Director/Data/ObjectImporter.php b/library/Director/Data/ObjectImporter.php index 1ff42f17a..1c3eb5382 100644 --- a/library/Director/Data/ObjectImporter.php +++ b/library/Director/Data/ObjectImporter.php @@ -93,6 +93,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); From bf27ca91721d697d28bf620b0056e11cc1bc0218 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 22 Jul 2026 11:02:59 +0200 Subject: [PATCH 189/221] Fix stale custom variable data left behind after delete or retype Share the value cleanup between deleting a property and retyping it away from a dictionary, and match stored icinga_*_var rows by varname instead of property_uuid, since that column is just an optional hint that isn't always populated while varname is the actual key. --- application/forms/CustomVariableForm.php | 14 + .../forms/DeleteCustomVariableForm.php | 358 +---------------- .../CustomVariableValueCleaner.php | 376 ++++++++++++++++++ .../Director/Form/CustomVariableFormTest.php | 265 ++++++++++++ .../Form/DeleteCustomVariableFormTest.php | 144 +++++++ 5 files changed, 811 insertions(+), 346 deletions(-) create mode 100644 library/Director/CustomVariable/CustomVariableValueCleaner.php diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index 577c4d3c1..2dab024ee 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -4,6 +4,7 @@ use Icinga\Data\Filter\Filter; use Icinga\Data\Filter\FilterException; +use Icinga\Module\Director\CustomVariable\CustomVariableValueCleaner; use Icinga\Module\Director\Data\Db\DbConnection; use Icinga\Module\Director\Db; use Icinga\Web\Session; @@ -545,6 +546,19 @@ private function updateExistingProperty( $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 diff --git a/application/forms/DeleteCustomVariableForm.php b/application/forms/DeleteCustomVariableForm.php index b6eecef26..9ea8b2504 100644 --- a/application/forms/DeleteCustomVariableForm.php +++ b/application/forms/DeleteCustomVariableForm.php @@ -3,7 +3,7 @@ namespace Icinga\Module\Director\Forms; use Icinga\Data\Filter\Filter; -use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\CustomVariable\CustomVariableValueCleaner; use Icinga\Module\Director\Db; use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\Web\Widget\CustomVarObjectList; @@ -17,8 +17,6 @@ use ipl\Web\Widget\Icon; use ipl\Web\Widget\ListItem; use Ramsey\Uuid\Uuid; -use Ramsey\Uuid\UuidInterface; -use Zend_Db; use Zend_Db_Expr; class DeleteCustomVariableForm extends CompatForm @@ -32,11 +30,15 @@ class DeleteCustomVariableForm extends CompatForm /** @var bool Whether the field is a nested field or not */ private $isNestedField = false; + /** @var CustomVariableValueCleaner */ + protected $cleaner; + public function __construct( protected Db $db, protected array $property, protected array $parent = [] ) { + $this->cleaner = new CustomVariableValueCleaner($db); } /** @@ -112,7 +114,7 @@ protected function assemble(): void . ' the corresponding custom variables from the below templates and objects.' . ' Are you sure you want to delete it?' ), - $this->fetchProperty(Uuid::fromBytes($this->parent['parent_uuid']))['key_name'] + $this->cleaner->fetchProperty(Uuid::fromBytes($this->parent['parent_uuid']))['key_name'] ); } else { $info = sprintf($this->translate( @@ -173,113 +175,12 @@ function (ListItem $item, $data) use (&$objectClass, $usageList) { ]); } - /** - * Fetch property for the given UUID - * - * @param UuidInterface $uuid UUID of the given property - * - * @return array - */ - private function fetchProperty(UuidInterface $uuid): array - { - $db = $this->db->getDbAdapter(); - - $query = $db - ->select() - ->from(['dp' => 'director_property'], []) - ->columns([ - 'key_name', - 'uuid', - 'parent_uuid', - 'value_type', - 'label', - 'description' - ]) - ->where('uuid = ?', DbUtil::quoteBinaryCompat($uuid->getBytes(), $db)); - - return DbUtil::normalizeRow($db->fetchRow($query, [], Zend_Db::FETCH_ASSOC) ?: []); - } - - /** - * Find the root property by following parent_uuid up from $parent, collecting - * key_names along the way. Dictionaries can nest arbitrarily deep, so the root isn't - * always $parent's direct parent. - * - * @param array $property The property being deleted - * @param array $parent The immediate parent of the property being deleted - * - * @return array{0: array, 1: string[]} [$rootProperty, $pathWithinRootValue] - * $pathWithinRootValue lists the key_names from directly under the root down to - * (and including) $property['key_name']. - */ - private 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 give data array - * - * @param array $item - * @param array $path - * - * @return void - */ - private function removeDictionaryItem(array &$item, array $path): void - { - $key = array_shift($path); - - if (! array_key_exists($key, $item)) { - return; - } - - if (empty($path)) { - unset($item[$key]); - } elseif (isset($item[$key]) && is_array($item[$key])) { - $this->removeDictionaryItem($item[$key], $path); - } - - // Remove empty arrays (but not scalar zero/false values) - if (isset($item[$key]) && is_array($item[$key]) && empty($item[$key])) { - unset($item[$key]); - } - } - - /** - * Strip the given path out of every entry in a dynamic dictionary, in place. Entries - * that aren't arrays are left alone, and it's up to the caller to decide what to do - * with an entry that ends up empty afterwards. - * - * @param array $dynamicDictionaryValue The dynamic dictionary's entries, modified in place - * @param string[] $path The path (relative to each entry) to strip - * - * @return void - */ - private function removeDictionaryItemFromEveryEntry(array &$dynamicDictionaryValue, array $path): void - { - foreach ($dynamicDictionaryValue as $entryKey => $entryValue) { - if (! is_array($entryValue)) { - continue; - } - - $this->removeDictionaryItem($dynamicDictionaryValue[$entryKey], $path); - } - } - protected function onSuccess(): void { $uuid = $this->property['uuid']; - $quotedUuid = DbUtil::quoteBinaryCompat($uuid, $this->db->getDbAdapter()); $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, @@ -288,15 +189,14 @@ protected function onSuccess(): void $allUuids = array_merge([$uuid], $this->collectDescendantUuids($uuid)); $quotedAllUuids = DbUtil::quoteBinaryCompat($allUuids, $this->db->getDbAdapter()); - $db->runFailSafeTransaction(function () use ($db, $prop, $quotedUuid, $quotedAllUuids) { + $db->runFailSafeTransaction(function () use ($db, $prop, $quotedAllUuids, $cleaner) { $db->delete('director_property_datalist', Filter::where('property_uuid', $quotedAllUuids)); - $this->removeObjectCustomVars($prop, $this->parent); - $this->removeFromOverrideServiceVars($prop, $this->parent); + $cleaner->removeObjectCustomVars($prop, $this->parent); + $cleaner->removeFromOverrideServiceVars($prop, $this->parent); - $objects = ['host', 'service', 'notification', 'command', 'user', 'service_set']; - foreach ($objects as $object) { - $this->db->delete("icinga_{$object}_var", Filter::where('property_uuid', $quotedUuid)); + if (empty($this->parent)) { + $cleaner->deleteStoredValues($prop['key_name']); } $db->delete('director_property', Filter::where('uuid', $quotedAllUuids)); @@ -331,238 +231,4 @@ private function collectDescendantUuids(string $uuid): array return array_merge(...$descendants); } - - /** - * Remove the deleted property's key from all hosts' _override_servicevars custom variable - * - * @param array $property The deleted property - * @param array $parent The parent property (empty for root properties) - * - * @return void - */ - private function removeFromOverrideServiceVars(array $property, array $parent): void - { - $db = $this->db->getDbAdapter(); - - // Get the configured override varname, falling back to the default - $overrideVarname = $db->fetchOne( - $db->select() - ->from('director_setting', ['setting_value']) - ->where('setting_name = ?', 'override_services_varname') - ) ?: '_override_servicevars'; - - // Determine the root property key, root type, and path within each service's root-key value - if (empty($parent)) { - // Root property deleted: remove its key_name from each service's override vars - $rootKeyName = $property['key_name']; - $rootType = $property['value_type']; - $pathWithinRootValue = null; - } else { - [$rootProp, $pathWithinRootValue] = $this->resolveRootProperty($property, $parent); - $rootKeyName = $rootProp['key_name']; - $rootType = $rootProp['value_type']; - } - - // Fetch all hosts that have the _override_servicevars custom variable - $query = $db->select() - ->from('icinga_host_var', ['host_id', 'varvalue']) - ->where('varname = ?', $overrideVarname); - - $rows = $db->fetchAll($query, [], Zend_Db::FETCH_ASSOC); - - foreach ($rows as $row) { - $overrideVars = json_decode($row['varvalue'], true); - if (! is_array($overrideVars)) { - continue; - } - - $modified = false; - foreach ($overrideVars as $serviceName => $serviceVars) { - if (! is_array($serviceVars) || ! array_key_exists($rootKeyName, $serviceVars)) { - continue; - } - - $modified = true; - - if ($pathWithinRootValue === null) { - // Root property deleted: remove its key from the service's override vars - unset($serviceVars[$rootKeyName]); - } elseif ($rootType === 'dynamic-dictionary') { - // Dynamic dictionary: remove the path from every dynamic entry, dropping - // any entry that becomes empty as a result - if (is_array($serviceVars[$rootKeyName])) { - $this->removeDictionaryItemFromEveryEntry($serviceVars[$rootKeyName], $pathWithinRootValue); - foreach ($serviceVars[$rootKeyName] as $entryKey => $entryValue) { - if ($entryValue === []) { - unset($serviceVars[$rootKeyName][$entryKey]); - } - } - } - - if (empty($serviceVars[$rootKeyName])) { - unset($serviceVars[$rootKeyName]); - } - } else { - // Fixed/static type: remove the nested path within the root key's value - $this->removeDictionaryItem($serviceVars[$rootKeyName], $pathWithinRootValue); - if (empty($serviceVars[$rootKeyName])) { - unset($serviceVars[$rootKeyName]); - } - } - - if (empty($serviceVars)) { - unset($overrideVars[$serviceName]); - } else { - $overrideVars[$serviceName] = $serviceVars; - } - } - - if (! $modified) { - continue; - } - - if (empty($overrideVars)) { - $db->delete('icinga_host_var', [ - 'host_id = ?' => $row['host_id'], - 'varname = ?' => $overrideVarname, - ]); - } else { - $db->update( - 'icinga_host_var', - ['varvalue' => json_encode($overrideVars)], - [ - 'host_id = ?' => $row['host_id'], - 'varname = ?' => $overrideVarname, - ] - ); - } - } - } - - private function removeObjectCustomVars(array $property, ?array $parent = null): void - { - if (empty($parent)) { - return; - } - - $db = $this->db->getDbAdapter(); - $parentUuid = Uuid::fromBytes($parent['uuid']); - - // Path within the stored JSON to the key being deleted — constant for all rows - [$rootProp, $path] = $this->resolveRootProperty($property, $parent); - $rootUuid = Uuid::fromBytes($rootProp['uuid']); - $rootType = $rootProp['value_type']; - - // Re-index the fixed-array items in director_property once, before processing stored vars - $isParentFixedArray = $parent['value_type'] === 'fixed-array'; - $isRootFixedArray = $rootType === 'fixed-array'; - if ($isParentFixedArray) { - $this->updateFixedArrayItems($parentUuid, $property['key_name']); - } - - foreach (['host', 'service', 'notification', 'command', 'user'] as $objectType) { - $idColumn = "{$objectType}_id"; - $varRows = $db->fetchAll( - $db->select() - ->from(['iov' => "icinga_{$objectType}_var"], []) - ->columns([$idColumn, 'varname', 'varvalue']) - ->where('property_uuid = ?', DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), - [], - Zend_Db::FETCH_ASSOC - ); - - $objectClass = DbObjectTypeRegistry::classByType($objectType); - - foreach ($varRows as $varRow) { - $varValue = json_decode($varRow['varvalue'], true); - - if ($rootType !== 'dynamic-dictionary') { - $this->removeDictionaryItem($varValue, $path); - } else { - $this->removeDictionaryItemFromEveryEntry($varValue, $path); - 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 ($isParentFixedArray && $rootUuid->equals($parentUuid)) { - // The fixed array is the root: the stored value is the bare list itself. - $varValue = array_values($varValue); - } elseif ($isRootFixedArray) { - $varValue = array_values($varValue); - } - - $vars->set($varRow['varname'], $varValue); - $vars->storeToDb($object); - } - } - } - - /** - * Update the items for the given fixed array - * - * @param UuidInterface $uuid - * @param string $propertyIndex - * - * @return void - */ - private function updateFixedArrayItems(UuidInterface $uuid, string $propertyIndex): void - { - $db = $this->db->getDbAdapter(); - $quotedUuid = DbUtil::quoteBinaryCompat($uuid->getBytes(), $db); - - // Delete the item being removed first, freeing up its key_name slot before any - // surviving sibling is renumbered into it — key_name is unique per parent_uuid. - $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 - ) - ); - // key_name is a varchar column; a fixed array's item indexes must be sorted numerically - // here, not lexicographically (otherwise '10' would sort before '2'). - usort($propItems, fn($a, $b) => (int) $a['key_name'] <=> (int) $b['key_name']); - - // Renumber survivors in place — everything else about each item's row (category, - // label, value_type, description, director_property_datalist link, and any other - // table referencing its uuid, such as icinga_host_property) is left untouched. - 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/CustomVariableValueCleaner.php b/library/Director/CustomVariable/CustomVariableValueCleaner.php new file mode 100644 index 000000000..a28c92931 --- /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[]} + */ + private 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/test/php/library/Director/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php index c485b4295..eeed5749f 100644 --- a/test/php/library/Director/Form/CustomVariableFormTest.php +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -7,7 +7,10 @@ use Icinga\Module\Director\Db\DbUtil; 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 @@ -18,6 +21,9 @@ class CustomVariableFormTest extends BaseTestCase /** @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()) { @@ -348,11 +354,270 @@ public function testUpdateDynamicArrayPropertyChangesItemType(): void ); } + public function testChangingRootPropertyTypeRemovesStaleHostVarEntirely(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // A host has "___TEST___network" set directly (not via a template Field, so + // used_count stays 0), a fixed-dictionary with a nested "interfaces" dictionary + // holding a "vlan" grandchild. + $host = IcingaHost::create([ + 'object_name' => '___TEST___lb01', + 'object_type' => 'object', + 'address' => '192.0.2.50', + ], $db); + $host->store(); + $this->createdHostNames[] = '___TEST___lb01'; + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___network', + 'value_type' => 'fixed-dictionary', + 'label' => 'Network', + ], $db)->store(); + $this->createdKeyNames[] = '___TEST___network'; + + $interfacesUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $interfacesUuid->getBytes(), + 'key_name' => 'interfaces', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'vlan', + 'parent_uuid' => $interfacesUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___network', + 'varvalue' => json_encode(['interfaces' => ['vlan' => '10']]), + 'format' => 'json', + 'property_uuid' => DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba), + ]); + + $form = new TestableCustomVariableForm($db, $rootUuid); + $form->setTestValues([ + 'key_name' => '___TEST___network', + 'value_type' => 'string', + 'label' => 'Network', + 'description' => null, + ]); + self::callMethod($form, 'onSuccess', []); + + $remainingChildren = $dba->fetchOne( + $dba->select() + ->from('director_property', ['cnt' => 'COUNT(*)']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba)) + ); + $this->assertSame('0', (string) $remainingChildren, 'interfaces and vlan must be dropped from the schema'); + + $hostVarRow = $dba->fetchRow( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___network') + ); + $this->assertFalse( + $hostVarRow, + 'the stale dictionary value must not survive retyping the root property to a string' + ); + } + + public function testChangingNestedPropertyTypeStripsOnlyItsOwnKeyFromHostVar(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // "interfaces" is retyped away from a fixed-dictionary, discarding its "vlan" + // grandchild. The sibling key "region" next to "interfaces" must survive untouched. + $host = IcingaHost::create([ + 'object_name' => '___TEST___lb02', + 'object_type' => 'object', + 'address' => '192.0.2.51', + ], $db); + $host->store(); + $this->createdHostNames[] = '___TEST___lb02'; + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___network2', + 'value_type' => 'fixed-dictionary', + 'label' => 'Network', + ], $db)->store(); + $this->createdKeyNames[] = '___TEST___network2'; + + $interfacesUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $interfacesUuid->getBytes(), + 'key_name' => 'interfaces', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'vlan', + 'parent_uuid' => $interfacesUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___network2', + 'varvalue' => json_encode([ + 'interfaces' => ['vlan' => '10'], + 'region' => 'us-east', + ]), + 'format' => 'json', + 'property_uuid' => DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba), + ]); + + $form = new TestableCustomVariableForm($db, $interfacesUuid, true, $rootUuid); + $form->setTestValues([ + 'key_name' => 'interfaces', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + ]); + self::callMethod($form, 'onSuccess', []); + + $vlanRow = $dba->fetchRow( + $dba->select()->from('director_property', ['uuid']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($interfacesUuid->getBytes(), $dba)) + ); + $this->assertFalse($vlanRow, 'vlan must be dropped from the schema'); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___network2') + ); + $this->assertEquals( + ['region' => 'us-east'], + json_decode($updatedValue, true), + 'interfaces must be dropped from the stored value while its sibling key region survives' + ); + } + + public function testChangingFixedArrayItemTypeClearsSlotWithoutShiftingSiblings(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // A fixed array of contacts, item 0 is a dictionary with an "ext" grandchild, item 1 + // is a plain string. Retyping item 0 to a string must clear its own slot in place + // without renumbering or otherwise touching item 1. + $host = IcingaHost::create([ + 'object_name' => '___TEST___lb03', + 'object_type' => 'object', + 'address' => '192.0.2.52', + ], $db); + $host->store(); + $this->createdHostNames[] = '___TEST___lb03'; + + $arrayUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $arrayUuid->getBytes(), + 'key_name' => '___TEST___contacts', + 'value_type' => 'fixed-array', + 'label' => 'Contacts', + ], $db)->store(); + $this->createdKeyNames[] = '___TEST___contacts'; + + $item0Uuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $item0Uuid->getBytes(), + 'key_name' => '0', + 'parent_uuid' => $arrayUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'ext', + 'parent_uuid' => $item0Uuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '1', + 'parent_uuid' => $arrayUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___contacts', + 'varvalue' => json_encode([['ext' => '123'], '555-1234']), + 'format' => 'json', + 'property_uuid' => DbUtil::quoteBinaryCompat($arrayUuid->getBytes(), $dba), + ]); + + $form = new TestableCustomVariableForm($db, $item0Uuid, true, $arrayUuid); + $form->setTestValues([ + 'key_name' => '0', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + ]); + self::callMethod($form, 'onSuccess', []); + + $item0Row = $dba->fetchRow( + $dba->select()->from('director_property', ['key_name', 'value_type']) + ->where('uuid = ?', DbUtil::quoteBinaryCompat($item0Uuid->getBytes(), $dba)) + ); + $this->assertNotFalse($item0Row, 'item 0 itself must survive, only its children are dropped'); + $this->assertSame('0', (string) $item0Row->key_name, 'item 0 must keep its own key_name'); + $this->assertSame('string', $item0Row->value_type, 'item 0 must carry the new value_type'); + + $item1Row = $dba->fetchRow( + $dba->select()->from('director_property', ['key_name']) + ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($arrayUuid->getBytes(), $dba)) + ->where('key_name = ?', '1') + ); + $this->assertNotFalse($item1Row, 'item 1 must not be renumbered or removed'); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___contacts') + ); + $this->assertEquals( + [null, '555-1234'], + json_decode($updatedValue, true), + 'item 0 must be nulled out in place, item 1 must be untouched, and the value must' + . ' stay a JSON array rather than turn into an object' + ); + } + 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() diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index ada68df30..172477ac8 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -601,6 +601,146 @@ public function testDeletingThreeLevelsDeepFieldUpdatesHostVarWithoutCrashing(): ); } + public function testDeletingFieldUpdatesHostVarEvenWithoutAPropertyUuidLink(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // property_uuid on icinga_host_var is an optional hint, not every stored row has it + // set (e.g. values entered directly through a host's own custom variables editor, + // rather than via a template's Fields tab). Deleting a field must still find and + // clean up such a row by varname. + $host = IcingaHost::create([ + 'object_name' => '___TEST___switch01', + 'object_type' => 'object', + 'address' => '192.0.2.60', + ], $db); + $host->store(); + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___network_config', + 'value_type' => 'fixed-dictionary', + 'label' => 'Network Config', + ], $db)->store(); + + $interfaceUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $interfaceUuid->getBytes(), + 'key_name' => 'management_interface', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + ], $db)->store(); + + $vlanUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $vlanUuid->getBytes(), + 'key_name' => 'vlan_id', + 'parent_uuid' => $interfaceUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___network_config', + 'varvalue' => json_encode([ + 'hostname' => 'sw01-mgmt', + 'management_interface' => ['vlan_id' => '100'], + ]), + 'format' => 'json', + 'property_uuid' => null, + ]); + + $form = new DeleteCustomVariableForm( + $db, + [ + 'uuid' => $vlanUuid->getBytes(), + 'key_name' => 'vlan_id', + 'value_type' => 'string', + 'label' => null, + 'description' => null, + 'parent_uuid' => $interfaceUuid->getBytes(), + ], + [ + 'uuid' => $interfaceUuid->getBytes(), + 'key_name' => 'management_interface', + 'value_type' => 'fixed-dictionary', + 'parent_uuid' => $rootUuid->getBytes(), + ] + ); + + self::callMethod($form, 'onSuccess', []); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___network_config') + ); + + $this->assertEquals( + ['hostname' => 'sw01-mgmt', 'management_interface' => (object) []], + json_decode($updatedValue, true), + 'vlan_id must be removed from the stored value even though property_uuid was' + . ' never linked on that row' + ); + } + + 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 testDeletingThreeLevelsDeepFieldUpdatesOverrideServiceVarsWithoutCrashing(): void { if ($this->skipForMissingDb()) { @@ -1073,6 +1213,8 @@ public function tearDown(): void '___TEST___multidc02', '___TEST___multidc03', '___TEST___oncall_template', + '___TEST___switch01', + '___TEST___switch02', ]; foreach ($hostNames as $hostName) { $dba->delete('icinga_host', ['object_name = ?' => $hostName]); @@ -1091,6 +1233,8 @@ public function tearDown(): void '___TEST___datacenter_settings_2', '___TEST___datacenter_settings_3', '___TEST___contact_numbers', + '___TEST___network_config', + '___TEST___snmp_community', ]; foreach ($keyNames as $keyName) { $rows = $dba->fetchAll( From eebfe98d698be27b3679e67e94ce68cfa96b60ba Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 22 Jul 2026 11:08:38 +0200 Subject: [PATCH 190/221] Fix rename propagation for custom variables without a property_uuid link updateUsedCustomVarNames had the same property_uuid matching issue as the delete and retype cleanup, so a rename never reached the stored varname or nested key when that column was unset. --- application/forms/CustomVariableForm.php | 11 +- .../Director/Form/CustomVariableFormTest.php | 108 ++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index 2dab024ee..f6d890322 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -752,16 +752,17 @@ private function updateUsedCustomVarNames(string $storedKeyName, mixed $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', - 'property_uuid' + 'varvalue' ]) - ->where('property_uuid = ?', Db\DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), + ->where('varname = ?', $root['key_name']), [], PDO::FETCH_ASSOC ); @@ -772,7 +773,7 @@ private function updateUsedCustomVarNames(string $storedKeyName, mixed $keyName) "icinga_{$objectType}_var", ['varname' => $keyName], Filter::matchAll( - Filter::where('property_uuid', Db\DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), + Filter::where('varname', $root['key_name']), Filter::where("{$objectType}_id", $objectCustomVar["{$objectType}_id"]) ) ); @@ -806,7 +807,7 @@ private function updateUsedCustomVarNames(string $storedKeyName, mixed $keyName) "icinga_{$objectType}_var", ['varvalue' => json_encode($varValue)], Filter::matchAll( - Filter::where('property_uuid', Db\DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $db)), + Filter::where('varname', $root['key_name']), Filter::where("{$objectType}_id", $objectCustomVar["{$objectType}_id"]) ) ); diff --git a/test/php/library/Director/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php index eeed5749f..379b5fc71 100644 --- a/test/php/library/Director/Form/CustomVariableFormTest.php +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -610,6 +610,114 @@ public function testChangingFixedArrayItemTypeClearsSlotWithoutShiftingSiblings( ); } + 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()) { From e446deaa2e8801427e7c3608d51e7d2517244215 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 22 Jul 2026 11:18:09 +0200 Subject: [PATCH 191/221] Fix rename path for grandchildren under fixed dictionaries and arrays updateUsedCustomVarNames only prefixed the parent key when renaming a nested field inside a dynamic dictionary, so a grandchild rename under a fixed dictionary or array looked for the field at the top level and silently missed it. Now reuses resolveRootProperty to build the full path regardless of nesting depth or root type. --- application/forms/CustomVariableForm.php | 29 ++- .../CustomVariableValueCleaner.php | 2 +- .../Director/Form/CustomVariableFormTest.php | 181 +++++++++++++++++- 3 files changed, 186 insertions(+), 26 deletions(-) diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index f6d890322..4ac024709 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -738,17 +738,19 @@ private function collectDescendantUuids(string $uuid): array private function updateUsedCustomVarNames(string $storedKeyName, mixed $keyName): void { $db = $this->db->getDbAdapter(); - $parent = []; + $cleaner = new CustomVariableValueCleaner($this->db); + if (! $this->parentUuid) { - $rootUuid = $this->uuid; - } elseif ($this->isNestedField) { - $parent = $this->fetchProperty($this->parentUuid); - $rootUuid = Uuid::fromBytes(Db\DbUtil::binaryResult($parent['parent_uuid'])); + $root = $this->fetchProperty($this->uuid); + $oldPath = null; + $newPath = null; } else { - $rootUuid = $this->parentUuid; + $parent = $this->fetchProperty($this->parentUuid); + [$root, $oldPath] = $cleaner->resolveRootProperty(['key_name' => $storedKeyName], $parent); + $newPath = array_slice($oldPath, 0, -1); + $newPath[] = $keyName; } - $root = $this->fetchProperty($rootUuid); $objectTypes = ['host', 'service', 'notification', 'command', 'user']; foreach ($objectTypes as $objectType) { @@ -785,18 +787,11 @@ private function updateUsedCustomVarNames(string $storedKeyName, mixed $keyName) foreach ($objectCustomVars as $objectCustomVar) { $varValue = json_decode($objectCustomVar['varvalue'], true); if ($root['value_type'] !== 'dynamic-dictionary') { - $this->updateObjectCustomVars([$storedKeyName], [$keyName], $varValue); + $this->updateObjectCustomVars($oldPath, $newPath, $varValue); } else { foreach ($varValue as $key => $value) { - if (! $this->isNestedField) { - $this->updateObjectCustomVars([$storedKeyName], [$keyName], $value); - } else { - $parenKey = $parent['key_name']; - $this->updateObjectCustomVars( - [$parenKey, $storedKeyName], - [$parenKey, $keyName], - $value - ); + if (is_array($value)) { + $this->updateObjectCustomVars($oldPath, $newPath, $value); } $varValue[$key] = $value; diff --git a/library/Director/CustomVariable/CustomVariableValueCleaner.php b/library/Director/CustomVariable/CustomVariableValueCleaner.php index a28c92931..3ce8cbddc 100644 --- a/library/Director/CustomVariable/CustomVariableValueCleaner.php +++ b/library/Director/CustomVariable/CustomVariableValueCleaner.php @@ -52,7 +52,7 @@ public function fetchProperty(UuidInterface $uuid): array * * @return array{0: array, 1: string[]} */ - private function resolveRootProperty(array $property, array $parent): array + public function resolveRootProperty(array $property, array $parent): array { $path = [$property['key_name']]; $current = $parent; diff --git a/test/php/library/Director/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php index 379b5fc71..367fd9f47 100644 --- a/test/php/library/Director/Form/CustomVariableFormTest.php +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -718,6 +718,148 @@ public function testRenamingUsedFieldUpdatesHostVarValueEvenWithoutAPropertyUuid ); } + public function testRenamingUsedGrandchildFieldUpdatesNestedPathInHostVar(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + // Renaming a grandchild, two levels below the root, must reach the correct nested + // path in the stored value rather than looking for the field name at the top level. + $host = IcingaHost::create([ + 'object_name' => '___TEST___switch05', + 'object_type' => 'object', + 'address' => '192.0.2.64', + ], $db); + $host->store(); + $this->createdHostNames[] = '___TEST___switch05'; + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___router_config', + 'value_type' => 'fixed-dictionary', + 'label' => 'Router Config', + ], $db)->store(); + $this->createdKeyNames[] = '___TEST___router_config'; + + $interfaceUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $interfaceUuid->getBytes(), + 'key_name' => 'wan_interface', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + ], $db)->store(); + + $mtuUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $mtuUuid->getBytes(), + 'key_name' => 'mtu', + 'parent_uuid' => $interfaceUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___router_config', + 'varvalue' => json_encode(['wan_interface' => ['mtu' => '1500', 'speed' => '1000']]), + 'format' => 'json', + 'property_uuid' => null, + ]); + + $form = new TestableCustomVariableForm($db, $mtuUuid, true, $interfaceUuid); + $form->setIsNestedField(true); + self::callMethod($form, 'updateUsedCustomVarNames', ['mtu', 'mtu_size']); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___router_config') + ); + $this->assertEquals( + ['wan_interface' => ['mtu_size' => '1500', 'speed' => '1000']], + json_decode($updatedValue, true), + 'mtu must be renamed to mtu_size at its nested path even though it is two levels' + . ' below the root property' + ); + } + + public function testRenamingUsedGrandchildFieldUpdatesEveryDynamicDictionaryEntry(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $host = IcingaHost::create([ + 'object_name' => '___TEST___switch06', + 'object_type' => 'object', + 'address' => '192.0.2.65', + ], $db); + $host->store(); + $this->createdHostNames[] = '___TEST___switch06'; + + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => '___TEST___datacenter_metrics', + 'value_type' => 'dynamic-dictionary', + 'label' => 'Datacenter Metrics', + ], $db)->store(); + $this->createdKeyNames[] = '___TEST___datacenter_metrics'; + + $cpuUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $cpuUuid->getBytes(), + 'key_name' => 'cpu', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + ], $db)->store(); + + $usageUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $usageUuid->getBytes(), + 'key_name' => 'usage_pct', + 'parent_uuid' => $cpuUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + $dba->insert('icinga_host_var', [ + 'host_id' => $host->get('id'), + 'varname' => '___TEST___datacenter_metrics', + 'varvalue' => json_encode([ + 'dc1' => ['cpu' => ['usage_pct' => '42', 'temp' => '55']], + 'dc2' => ['cpu' => ['usage_pct' => '10']], + ]), + 'format' => 'json', + 'property_uuid' => null, + ]); + + $form = new TestableCustomVariableForm($db, $usageUuid, true, $cpuUuid); + $form->setIsNestedField(true); + self::callMethod($form, 'updateUsedCustomVarNames', ['usage_pct', 'usage_percent']); + + $updatedValue = $dba->fetchOne( + $dba->select()->from('icinga_host_var', ['varvalue']) + ->where('host_id = ?', $host->get('id')) + ->where('varname = ?', '___TEST___datacenter_metrics') + ); + $this->assertEquals( + [ + 'dc1' => ['cpu' => ['usage_percent' => '42', 'temp' => '55']], + 'dc2' => ['cpu' => ['usage_percent' => '10']], + ], + json_decode($updatedValue, true), + 'usage_pct must be renamed to usage_percent inside every datacenter entry, at its' + . ' correct nested path under cpu' + ); + } + public function tearDown(): void { if ($this->hasDb()) { @@ -733,14 +875,7 @@ public function tearDown(): void ->where('key_name = ?', $keyName) ); 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 = ?', $keyName)); + $this->deletePropertyTree($dba, DbUtil::binaryResult($row->uuid)); } } @@ -753,4 +888,34 @@ public function tearDown(): void 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)) + ); + } } From 12bd049dcfc8f3f2c271d7da8b470464e8d210d9 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 22 Jul 2026 11:29:18 +0200 Subject: [PATCH 192/221] Fix basket diff missing nested custom variable changes Exporter never carried a DirectorProperty's child items or datalist link since they aren't plain DB columns, so both sides of a basket diff always compared an empty tree and every nested change looked like nothing changed. Now Exporter includes them via export(), and BasketDiff restores the snapshot's own items/datalist afterwards instead of trusting a re-export of the live row. ObjectImporter also needed to drop those two keys before setProperties(), same as it already does for fields and customVariables. --- library/Director/Data/Exporter.php | 8 + library/Director/Data/ObjectImporter.php | 4 + .../DirectorObject/Automation/BasketDiff.php | 17 ++ .../Automation/BasketDiffTest.php | 176 ++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php diff --git a/library/Director/Data/Exporter.php b/library/Director/Data/Exporter.php index cf6b3566f..a6224703b 100644 --- a/library/Director/Data/Exporter.php +++ b/library/Director/Data/Exporter.php @@ -180,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; + } } } diff --git a/library/Director/Data/ObjectImporter.php b/library/Director/Data/ObjectImporter.php index 1c3eb5382..cc1da2952 100644 --- a/library/Director/Data/ObjectImporter.php +++ b/library/Director/Data/ObjectImporter.php @@ -57,6 +57,10 @@ public function import(string $implementation, stdClass $plain): DbObject 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'])) { diff --git a/library/Director/DirectorObject/Automation/BasketDiff.php b/library/Director/DirectorObject/Automation/BasketDiff.php index c18504c78..7f5fb12d3 100644 --- a/library/Director/DirectorObject/Automation/BasketDiff.php +++ b/library/Director/DirectorObject/Automation/BasketDiff.php @@ -96,6 +96,8 @@ 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) ); @@ -111,6 +113,21 @@ protected function getBasket($type, $key): stdClass $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/test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php b/test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php new file mode 100644 index 000000000..2abacf5e7 --- /dev/null +++ b/test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php @@ -0,0 +1,176 @@ + +// 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 testDiffDetectsGrandchildRemovedFromCustomVariable(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + + // A "network_config" fixed dictionary with a nested "interfaces" dictionary that + // itself carries a "vlan_id" grandchild, the same shape as the reported bug. + $rootUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $rootUuid->getBytes(), + 'key_name' => self::PREFIX . 'network_config', + 'value_type' => 'fixed-dictionary', + 'label' => 'Network Config', + ], $db)->store(); + + $interfaceUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $interfaceUuid->getBytes(), + 'key_name' => 'interfaces', + 'parent_uuid' => $rootUuid->getBytes(), + 'value_type' => 'fixed-dictionary', + ], $db)->store(); + + $vlanUuid = Uuid::uuid4(); + DirectorProperty::create([ + 'uuid' => $vlanUuid->getBytes(), + 'key_name' => 'vlan_id', + 'parent_uuid' => $interfaceUuid->getBytes(), + 'value_type' => 'string', + ], $db)->store(); + + // Snapshot the property while the grandchild still exists. + $propertyUuidString = $rootUuid->toString(); + $exportedProperty = DirectorProperty::loadWithUniqueId($rootUuid, $db)->export(); + $basket = Basket::create(['uuid' => Uuid::uuid4()->getBytes(), 'basket_name' => self::PREFIX . 'basket1']); + $snapshot = BasketSnapshot::forBasketFromJson( + $basket, + json_encode(['CustomVariable' => [$propertyUuidString => $exportedProperty]]) + ); + + // Now delete the grandchild, exactly as reported in the bug. + $dba = $db->getDbAdapter(); + $dba->delete( + 'director_property', + $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($vlanUuid->getBytes(), $dba)) + ); + + $diff = new BasketDiff($snapshot, $db); + + $this->assertTrue( + $diff->hasChangedFor('CustomVariable', $propertyUuidString, $rootUuid), + 'removing a grandchild from a custom variable must show up as a change in the basket diff' + ); + + $this->assertStringContainsString( + 'vlan_id', + $diff->getBasketString('CustomVariable', $propertyUuidString), + 'the basket side must still show the grandchild as it was at snapshot time' + ); + $this->assertStringNotContainsString( + 'vlan_id', + $diff->getCurrentString('CustomVariable', $propertyUuidString, $rootUuid), + 'the current side must reflect that the grandchild no longer exists' + ); + } + + 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 . 'network_config', 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)) + ); + } +} From 5f34747a0d23c9dca574e7f31ec689d488800aac Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 22 Jul 2026 11:46:37 +0200 Subject: [PATCH 193/221] Replace placeholder test data with realistic examples Swapped generic keys and values like k, dup_field, and s3cr3t-value for things a real user would actually type, DNS servers, disk thresholds, TLS paths, API keys, template names and the like across the custom variable, migrate, REST API and sensitive element tests. --- .../CustomVariable/CustomVariableTest.php | 74 +++++++++++-------- .../CustomVariableValueValidatorTest.php | 18 +++-- .../Director/Form/CustomVariablesFormTest.php | 6 +- .../Director/Objects/DirectorPropertyTest.php | 18 ++--- .../Director/Objects/IcingaServiceTest.php | 2 +- .../Director/Objects/MigrateCommandTest.php | 22 +++--- .../RestApi/IcingaObjectHandlerTest.php | 6 +- .../Web/Form/Element/SensitiveElementTest.php | 10 +-- 8 files changed, 88 insertions(+), 68 deletions(-) diff --git a/test/php/library/Director/CustomVariable/CustomVariableTest.php b/test/php/library/Director/CustomVariable/CustomVariableTest.php index 363ee55fb..7dae982f3 100644 --- a/test/php/library/Director/CustomVariable/CustomVariableTest.php +++ b/test/php/library/Director/CustomVariable/CustomVariableTest.php @@ -24,47 +24,59 @@ class CustomVariableTest extends TestCase public function testCreateNullReturnsNull(): void { - $this->assertInstanceOf(CustomVariableNull::class, CustomVariable::create('k', null)); + $this->assertInstanceOf(CustomVariableNull::class, CustomVariable::create('notes', null)); } public function testCreateBoolTrueReturnsBoolean(): void { - $this->assertInstanceOf(CustomVariableBoolean::class, CustomVariable::create('k', true)); + $this->assertInstanceOf( + CustomVariableBoolean::class, + CustomVariable::create('notifications_enabled', true) + ); } public function testCreateBoolFalseReturnsBoolean(): void { - $this->assertInstanceOf(CustomVariableBoolean::class, CustomVariable::create('k', false)); + $this->assertInstanceOf( + CustomVariableBoolean::class, + CustomVariable::create('notifications_enabled', false) + ); } public function testCreateIntegerReturnsNumber(): void { - $this->assertInstanceOf(CustomVariableNumber::class, CustomVariable::create('k', 42)); + $this->assertInstanceOf(CustomVariableNumber::class, CustomVariable::create('max_check_attempts', 3)); } public function testCreateFloatReturnsNumber(): void { - $this->assertInstanceOf(CustomVariableNumber::class, CustomVariable::create('k', 3.14)); + $this->assertInstanceOf(CustomVariableNumber::class, CustomVariable::create('load_threshold', 3.14)); } public function testCreateStringReturnsString(): void { - $this->assertInstanceOf(CustomVariableString::class, CustomVariable::create('k', 'hello')); + $this->assertInstanceOf(CustomVariableString::class, CustomVariable::create('env', 'production')); } public function testCreateIndexedArrayReturnsArray(): void { - $this->assertInstanceOf(CustomVariableArray::class, CustomVariable::create('k', ['a', 'b', 'c'])); + $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('k', [])); + $this->assertInstanceOf(CustomVariableArray::class, CustomVariable::create('excluded_checks', [])); } public function testCreateAssociativeArrayReturnsDictionary(): void { - $this->assertInstanceOf(CustomVariableDictionary::class, CustomVariable::create('k', ['key' => 'val'])); + $this->assertInstanceOf( + CustomVariableDictionary::class, + CustomVariable::create('disk_thresholds', ['warn' => '20%']) + ); } public function testCreateMixedKeyArrayReturnsDictionary(): void @@ -72,26 +84,26 @@ public function testCreateMixedKeyArrayReturnsDictionary(): void // Mixed numeric and string keys → dictionary because at least one key is non-integer $this->assertInstanceOf( CustomVariableDictionary::class, - CustomVariable::create('k', [0 => 'a', 'label' => 'b']) + CustomVariable::create('interfaces', [0 => 'eth0', 'label' => 'Primary Interface']) ); } public function testCreateObjectReturnsDictionary(): void { $obj = (object) ['warn' => '20%', 'crit' => '10%']; - $this->assertInstanceOf(CustomVariableDictionary::class, CustomVariable::create('k', $obj)); + $this->assertInstanceOf(CustomVariableDictionary::class, CustomVariable::create('disk_thresholds', $obj)); } public function testCreatePreservesKey(): void { - $var = CustomVariable::create('my_key', 'value'); - $this->assertEquals('my_key', $var->getKey()); + $var = CustomVariable::create('environment', 'production'); + $this->assertEquals('environment', $var->getKey()); } public function testCreatePreservesValue(): void { - $var = CustomVariable::create('k', 'hello'); - $this->assertEquals('hello', $var->getValue()); + $var = CustomVariable::create('env', 'production'); + $this->assertEquals('production', $var->getValue()); } // ------------------------------------------------------------------------- @@ -140,7 +152,7 @@ public function testFromDbRowJsonObjectCreatesDictionary(): void { $row = (object) [ 'format' => 'json', - 'varname' => 'thresholds', + 'varname' => 'disk_thresholds', 'varvalue' => json_encode(['warn' => '20%', 'crit' => '10%']), ]; @@ -165,8 +177,8 @@ public function testFromDbRowUnknownFormatThrows(): void CustomVariable::fromDbRow((object) [ 'format' => 'binary', - 'varname' => 'data', - 'varvalue' => 'abc', + 'varname' => 'snmp_community', + 'varvalue' => 'cGFzc3dvcmQ=', ]); } @@ -266,22 +278,22 @@ public function testFromDbRowMarksVarAsUnmodified(): void public function testEqualDictionariesAreEqual(): void { - $a = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); - $b = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); + $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('k', ['crit' => '10%', 'warn' => '20%']); - $b = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); + $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('k', ['warn' => '20%', 'crit' => '10%']); + $dict = CustomVariable::create('disk_thresholds', ['warn' => '20%', 'crit' => '10%']); assert($dict instanceof CustomVariableDictionary); $this->assertSame('{"crit":"10%","warn":"20%"}', $dict->getDbValue()); @@ -289,22 +301,22 @@ public function testGetDbValueSerializesKeysInSortedOrder(): void public function testDictionariesWithDifferentKeysAreNotEqual(): void { - $a = CustomVariable::create('k', ['warn' => '20%', 'crit' => '10%']); - $b = CustomVariable::create('k', ['warn' => '20%']); + $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('k', ['warn' => '20%', 'crit' => '10%']); - $b = CustomVariable::create('k', ['warn' => '30%', 'crit' => '10%']); + $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('k', ['warn' => '20%']); - $str = CustomVariable::create('k', 'hello'); + $dict = CustomVariable::create('disk_thresholds', ['warn' => '20%']); + $str = CustomVariable::create('env', 'production'); $this->assertFalse($dict->equals($str)); } @@ -313,8 +325,8 @@ 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('k', []); - $b = CustomVariable::create('k', []); + $a = CustomVariable::create('disk_thresholds', []); + $b = CustomVariable::create('disk_thresholds', []); $this->assertTrue($a->equals($b)); } } diff --git a/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php b/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php index 52af0e73f..3f2daf135 100644 --- a/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php +++ b/test/php/library/Director/CustomVariable/CustomVariableValueValidatorTest.php @@ -16,7 +16,7 @@ class CustomVariableValueValidatorTest extends BaseTestCase public function testStringValueRejectsArray(): void { $this->expectException(InvalidArgumentException::class); - CustomVariableValueValidator::assertMatchesType('env', ['a', 'b'], 'string'); + CustomVariableValueValidator::assertMatchesType('env', ['staging', 'production'], 'string'); } public function testStringValueAcceptsNumericString(): void @@ -28,24 +28,32 @@ public function testStringValueAcceptsNumericString(): void public function testArrayValueRejectsDictionary(): void { $this->expectException(InvalidArgumentException::class); - CustomVariableValueValidator::assertMatchesType('ssh_args', (object) ['a' => 'b'], 'dynamic-array'); + CustomVariableValueValidator::assertMatchesType( + 'ssh_args', + (object) ['StrictHostKeyChecking' => 'no'], + 'dynamic-array' + ); } public function testArrayValueAcceptsList(): void { - CustomVariableValueValidator::assertMatchesType('ssh_args', ['a', 'b'], 'fixed-array'); + CustomVariableValueValidator::assertMatchesType('ssh_args', ['-4', '-C'], 'fixed-array'); $this->addToAssertionCount(1); } public function testDictionaryValueRejectsList(): void { $this->expectException(InvalidArgumentException::class); - CustomVariableValueValidator::assertMatchesType('mysql', ['a', 'b'], 'dynamic-dictionary'); + CustomVariableValueValidator::assertMatchesType('mysql', ['3306', 'root'], 'dynamic-dictionary'); } public function testDictionaryValueAcceptsObject(): void { - CustomVariableValueValidator::assertMatchesType('mysql', (object) ['host' => 'db'], 'fixed-dictionary'); + CustomVariableValueValidator::assertMatchesType( + 'mysql', + (object) ['host' => 'db01.example.com'], + 'fixed-dictionary' + ); $this->addToAssertionCount(1); } diff --git a/test/php/library/Director/Form/CustomVariablesFormTest.php b/test/php/library/Director/Form/CustomVariablesFormTest.php index d2a7e9e29..780313257 100644 --- a/test/php/library/Director/Form/CustomVariablesFormTest.php +++ b/test/php/library/Director/Form/CustomVariablesFormTest.php @@ -17,7 +17,7 @@ class CustomVariablesFormTest extends BaseTestCase public function testAttachingNewPropertyToNonTemplateThrows(): void { $host = IcingaHost::create([ - 'object_name' => 'apitest', + 'object_name' => 'app-server-01', 'object_type' => 'object', ]); $form = new CustomVariablesForm($host); @@ -221,9 +221,9 @@ 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' => ['a', '', 'c']]; + $entry = ['label' => 'dc1', 'slots' => ['eth0', '', 'eth2']]; $result = CustomVariablesForm::filterEmpty($entry); - $this->assertSame(['label' => 'dc1', 'slots' => ['a', '', 'c']], $result); + $this->assertSame(['label' => 'dc1', 'slots' => ['eth0', '', 'eth2']], $result); } public function testFixedArrayNestedInsideDictionaryEntryIsDroppedWhenFullyEmpty(): void diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 621758435..b268274fc 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -164,7 +164,7 @@ public function testDynamicDictionaryNestingIsRejectedByTheModel(): void $child = DirectorProperty::create([ 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'nested', + 'key_name' => 'thresholds', 'parent_uuid' => $parentUuid, 'value_type' => 'dynamic-dictionary', ], $db); @@ -187,7 +187,7 @@ public function testSensitiveCannotBeNestedInsideADynamicArray(): void $child = DirectorProperty::create([ 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'item', + 'key_name' => '0', 'parent_uuid' => $parent->get('uuid'), 'value_type' => 'sensitive', ], $db); @@ -318,7 +318,7 @@ public function testDatalistChildOfDynamicDictionaryIsPersistedWhenListDoesNotEx 'category' => null, 'description' => null, 'items' => [ - 'choice' => $this->datalistItemPlain('choice', $parentUuid, $listName), + 'severity' => $this->datalistItemPlain('severity', $parentUuid, $listName), ], ]; @@ -360,7 +360,7 @@ public function testDatalistGrandchildOfDynamicDictionaryIsPersistedWhenListDoes $this->assertFalse(DirectorDatalist::exists($listName, $db), 'Precondition: datalist must not exist yet'); $parentUuid = Uuid::uuid4()->toString(); - $groupUuid = Uuid::uuid4()->toString(); + $teamUuid = Uuid::uuid4()->toString(); $plain = (object) [ 'uuid' => $parentUuid, 'key_name' => $parentKeyName, @@ -370,16 +370,16 @@ public function testDatalistGrandchildOfDynamicDictionaryIsPersistedWhenListDoes 'category' => null, 'description' => null, 'items' => [ - 'group' => (object) [ - 'uuid' => $groupUuid, - 'key_name' => 'group', + 'team' => (object) [ + 'uuid' => $teamUuid, + 'key_name' => 'team', 'value_type' => 'fixed-dictionary', 'label' => null, 'parent_uuid' => $parentUuid, 'category' => null, 'description' => null, 'items' => [ - 'choice' => $this->datalistItemPlain('choice', $groupUuid, $listName), + 'severity' => $this->datalistItemPlain('severity', $teamUuid, $listName), ], ], ], @@ -394,7 +394,7 @@ public function testDatalistGrandchildOfDynamicDictionaryIsPersistedWhenListDoes } } - $reloadedGroup = DirectorProperty::loadWithUniqueId(Uuid::fromString($groupUuid), $db); + $reloadedGroup = DirectorProperty::loadWithUniqueId(Uuid::fromString($teamUuid), $db); $grandchildren = $reloadedGroup->fetchItemsFromDb(); $this->assertCount(1, $grandchildren); diff --git a/test/php/library/Director/Objects/IcingaServiceTest.php b/test/php/library/Director/Objects/IcingaServiceTest.php index 05071d2f3..84856206e 100644 --- a/test/php/library/Director/Objects/IcingaServiceTest.php +++ b/test/php/library/Director/Objects/IcingaServiceTest.php @@ -250,7 +250,7 @@ public function testApplyForConfigMacroStaysBackwardCompatibleWithValue() // 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.test1'; + $service->apply_for = 'host.vars.disks'; $service->assign_filter = 'host.vars.env="test"'; $service->{'vars.legacy_macro'} = '/dev/$config$'; diff --git a/test/php/library/Director/Objects/MigrateCommandTest.php b/test/php/library/Director/Objects/MigrateCommandTest.php index 4190c60f8..d10f44884 100644 --- a/test/php/library/Director/Objects/MigrateCommandTest.php +++ b/test/php/library/Director/Objects/MigrateCommandTest.php @@ -41,7 +41,7 @@ class MigrateCommandTest extends BaseTestCase // Migratable as 'sensitive' (legacy hidden-visibility string) private const VAR_HIDDEN = self::PREFIX . 'snmp_community'; - private const VAR_DUP = self::PREFIX . 'dup_field'; + private const VAR_DUP = self::PREFIX . 'notification_email'; private const VAR_TIME_FIELD = self::PREFIX . 'time_field'; @@ -73,8 +73,8 @@ class MigrateCommandTest extends BaseTestCase self::VAR_HIDDEN, self::VAR_DUP, self::VAR_TIME_FIELD, - self::PREFIX . 'rollback_first', - self::PREFIX . 'rollback_second', + self::PREFIX . 'tls_cert_path', + self::PREFIX . 'tls_key_path', ]; public function testDryRunPrintsWhatWouldMigrateWithoutWriting(): void @@ -422,19 +422,19 @@ public function testMigrateDatafieldsRollsBackOnMidLoopFailure(): void $sharedUuid = Uuid::uuid4()->getBytes(); $customProperties = [ - self::PREFIX . 'rollback_first' => [ + self::PREFIX . 'tls_cert_path' => [ 'datafield_id' => 90001, 'uuid' => $sharedUuid, - 'key_name' => self::PREFIX . 'rollback_first', + 'key_name' => self::PREFIX . 'tls_cert_path', 'label' => null, 'description' => null, 'category_id' => null, 'value_type' => 'string', ], - self::PREFIX . 'rollback_second' => [ + self::PREFIX . 'tls_key_path' => [ 'datafield_id' => 90002, 'uuid' => $sharedUuid, - 'key_name' => self::PREFIX . 'rollback_second', + 'key_name' => self::PREFIX . 'tls_key_path', 'label' => null, 'description' => null, 'category_id' => null, @@ -458,7 +458,7 @@ public function testMigrateDatafieldsRollsBackOnMidLoopFailure(): void $count = $dba->fetchOne( $dba->select()->from('director_property', ['cnt' => 'COUNT(*)']) - ->where('key_name = ?', self::PREFIX . 'rollback_first') + ->where('key_name = ?', self::PREFIX . 'tls_cert_path') ); $this->assertEquals( 0, @@ -714,19 +714,19 @@ private function createAllFixtures(Db $db): void $field->set('visibility', 'hidden'); $field->store(); - // 9. dup_field × 2 — duplicate varname (skip both) + // 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' => 'Dup A', + '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' => 'Dup B', + 'caption' => 'Notification Email (added by the NOC)', 'datatype' => 'Icinga\Module\Director\DataType\DataTypeString', ]); diff --git a/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php b/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php index 1ff853d33..0554010ee 100644 --- a/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php +++ b/test/php/library/Director/RestApi/IcingaObjectHandlerTest.php @@ -38,7 +38,7 @@ public function testObjectChangeAndCustomVarValidationFailureRollBackTogether(): $host = IcingaHost::create([ 'object_name' => self::TEMPLATE_NAME, 'object_type' => 'template', - 'display_name' => 'original-name', + 'display_name' => 'Webserver Template', ]); $host->store($db); @@ -62,7 +62,7 @@ public function testObjectChangeAndCustomVarValidationFailureRollBackTogether(): $writeRequest = new IcingaObjectWriteRequest( $host, - ['display_name' => 'changed-by-request'], + ['display_name' => 'Webserver Template (renamed)'], 'host', 'index', 'PUT', @@ -89,7 +89,7 @@ public function testObjectChangeAndCustomVarValidationFailureRollBackTogether(): $reloaded = IcingaHost::load(self::TEMPLATE_NAME, $db); $this->assertEquals( - 'original-name', + 'Webserver Template', $reloaded->get('display_name'), 'The object property change must not survive a custom-variable validation ' . 'failure that happens in the same request' diff --git a/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php b/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php index b3c7219af..2c9c32cec 100644 --- a/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php +++ b/test/php/library/Director/Web/Form/Element/SensitiveElementTest.php @@ -20,9 +20,9 @@ public function testGetValueReturnsEmptyStringWhenNothingWasEverEntered(): void public function testGetValueReturnsTheEnteredValue(): void { $element = new SensitiveElement('api_token'); - $element->setValue('s3cr3t-value'); + $element->setValue('sk_live_4f8a1c9d2b7e'); - $this->assertSame('s3cr3t-value', $element->getValue()); + $this->assertSame('sk_live_4f8a1c9d2b7e', $element->getValue()); } public function testWasSubmittedUnchangedIsFalseWhenNothingWasEverEntered(): void @@ -43,7 +43,7 @@ public function testWasSubmittedUnchangedIsFalseWhenExplicitlyEmptied(): void public function testWasSubmittedUnchangedIsFalseWhenGivenANewValue(): void { $element = new SensitiveElement('api_token'); - $element->setValue('new-value'); + $element->setValue('sk_live_9d3e7b2a6f10'); $this->assertFalse($element->wasSubmittedUnchanged()); } @@ -75,11 +75,11 @@ public function testRenderedValueAttributeShowsAFreshlyTypedValue(): void // 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('freshly-typed-value'); + $element->setValue('sk_live_00ff11ee22dd'); $html = (string) $element; - $this->assertStringContainsString('value="freshly-typed-value"', $html); + $this->assertStringContainsString('value="sk_live_00ff11ee22dd"', $html); } public function testRenderedValueAttributeIsAbsentWhenExplicitlyEmptied(): void From 7087c849d3f1a17ad516cc966ec4d437b871ad73 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 22 Jul 2026 11:51:10 +0200 Subject: [PATCH 194/221] Fix wrong expectation in the property_uuid-less delete test vlan_id was the only key under management_interface, so removing it collapses management_interface away too, that has always been how removeDictionaryItem cleans up an emptied container. The test wrongly expected it to survive as an empty object. --- .../library/Director/Form/DeleteCustomVariableFormTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index 172477ac8..4a4d617da 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -683,10 +683,11 @@ public function testDeletingFieldUpdatesHostVarEvenWithoutAPropertyUuidLink(): v ); $this->assertEquals( - ['hostname' => 'sw01-mgmt', 'management_interface' => (object) []], + ['hostname' => 'sw01-mgmt'], json_decode($updatedValue, true), 'vlan_id must be removed from the stored value even though property_uuid was' - . ' never linked on that row' + . ' never linked on that row, and management_interface must collapse away' + . ' since vlan_id was its only key' ); } From 02d32e269191648df0eb7a2665969392c740f400 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 22 Jul 2026 12:26:00 +0200 Subject: [PATCH 195/221] DictionaryItem: Fix 'item_required' affiliation --- application/forms/DictionaryElements/DictionaryItem.php | 9 +++++---- .../Form/DictionaryElements/DictionaryItemTest.php | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index a8cd3114b..93c9ac14d 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -107,7 +107,7 @@ protected function assemble(): void if ($this->removeButton !== null) { $this->addAttributes(['class' => ['removable']]); - $this->addElement('checkbox', 'item_required', [ + $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' @@ -681,13 +681,14 @@ public function getItem(bool $applyUnchangedDefaults = true): array } } - $markForRemovalElement = 'delete-' . $this->getName(); + $itemName = $this->getName(); + $markForRemovalElement = 'delete-' . $itemName; if ($this->hasElement($markForRemovalElement)) { $values['delete'] = $this->getElement($markForRemovalElement)->getValue(); } - if ($this->hasElement('item_required')) { - $values['required'] = $this->getElement('item_required')->getValue() === 'y'; + if ($this->hasElement('item_required_' . $itemName)) { + $values['required'] = $this->getElement('item_required_' . $itemName)->getValue() === 'y'; } return $values; diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index 5da3689a7..1f05d1f4c 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -595,7 +595,7 @@ public function testRequiredToggleIsRenderedNextToTheRemoveButton(): void $item = $this->buildScalarDictionaryItemWithRemoveButton(requiredCurrent: true); - $this->assertTrue($item->hasElement('item_required')); + $this->assertTrue($item->hasElement('item_required_' . $item->getName())); } public function testRequiredToggleIsSeededFromTheStoredRequiredFlag(): void @@ -606,7 +606,7 @@ public function testRequiredToggleIsSeededFromTheStoredRequiredFlag(): void $item = $this->buildScalarDictionaryItemWithRemoveButton(requiredCurrent: true); - $this->assertSame('y', $item->getElement('item_required')->getValue()); + $this->assertSame('y', $item->getElement('item_required_' . $item->getName())->getValue()); } public function testRequiredToggleIsNotRenderedWithoutARemoveButton(): void @@ -618,7 +618,7 @@ public function testRequiredToggleIsNotRenderedWithoutARemoveButton(): void // 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')); + $this->assertFalse($item->hasElement('item_required_' . $item->getName())); } public function testGetItemReportsTheToggledRequiredFlag(): void @@ -628,7 +628,7 @@ public function testGetItemReportsTheToggledRequiredFlag(): void } $item = $this->buildScalarDictionaryItemWithRemoveButton(requiredCurrent: true); - $item->getElement('item_required')->setValue('n'); + $item->getElement('item_required_' . $item->getName())->setValue('n'); $this->assertFalse($item->getItem()['required']); } From 9e8c11639534965d4ed865cec3fc556840f4d268 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Wed, 22 Jul 2026 16:54:04 +0200 Subject: [PATCH 196/221] Stamp property_uuid on custom var rows during basket restore Reuse the cached target properties instead of reloading each one from the DB, call storeToDb() once per object instead of once per property, and guard the targetProperties lookup with an isset check before reading key_name from it. Added a test covering the restored property_uuid on icinga_host_var. --- .../BasketSnapshotCustomVariableResolver.php | 11 +++++ .../BasketSnapshotCustomVariableTest.php | 42 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php index 9587aa47b..a3a6beacf 100644 --- a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php +++ b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php @@ -117,6 +117,7 @@ public function relinkObjectCustomProperties(IcingaObject $new, $object): void $existingCustomProperties[Uuid::fromBytes($propertyUuid)->toString()] = $mapping; } + $targetProperties = $this->getTargetProperties(); foreach ($object->customVariables as $property) { $propertyUuid = DbUtil::binaryResult($property->property_uuid); if (! isset($customPropertyMap[$propertyUuid])) { @@ -144,8 +145,18 @@ public function relinkObjectCustomProperties(IcingaObject $new, $object): void '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(), diff --git a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php index 4e268b3db..6a6b1f88f 100644 --- a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php +++ b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php @@ -244,6 +244,48 @@ public function testRestoreRemovesChildItemsNotInSnapshot(): void ); } + 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 testRestoreIsIdempotent(): void { if ($this->skipForMissingDb()) { From c8e694597552a41e8291fcdcfa3c766f2b83afb1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Thu, 23 Jul 2026 12:30:58 +0200 Subject: [PATCH 197/221] CustomVarFieldsTable: Center the text in the table vertically for each cell --- library/Director/Web/Widget/CustomVarFieldsTable.php | 9 +++++---- public/css/custom-var-fields-table.less | 12 +++++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/library/Director/Web/Widget/CustomVarFieldsTable.php b/library/Director/Web/Widget/CustomVarFieldsTable.php index cb3b42660..ee807f812 100644 --- a/library/Director/Web/Widget/CustomVarFieldsTable.php +++ b/library/Director/Web/Widget/CustomVarFieldsTable.php @@ -5,6 +5,7 @@ use Icinga\Module\Director\Db\DbUtil; use ipl\Html\HtmlElement; use ipl\Html\Table; +use ipl\Html\Text; use ipl\I18n\Translation; use ipl\Web\Url; use ipl\Web\Widget\Link; @@ -45,14 +46,14 @@ protected function assemble(): void $columns = [ static::td([HtmlElement::create('strong', null, new Link($property->key_name, $url))]) ->setSeparator(' '), - static::td([HtmlElement::create('p', null, $property->label)])->setSeparator(' '), - static::td([HtmlElement::create('p', null, $property->value_type)]), + 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([HtmlElement::create('p', null, $this->translate('In use'))]); + $columns[] = static::td([Text::create($this->translate('In use'))]); } else { - $columns[] = static::td([HtmlElement::create('p', null, $this->translate('Not in use'))]); + $columns[] = static::td([Text::create($this->translate('Not in use'))]); } $this->addHtml(static::tr($columns)); diff --git a/public/css/custom-var-fields-table.less b/public/css/custom-var-fields-table.less index 5292b441f..3f7c396b1 100644 --- a/public/css/custom-var-fields-table.less +++ b/public/css/custom-var-fields-table.less @@ -1,3 +1,13 @@ .common-table.custom-var-fields-table { max-width: 100%; -} \ No newline at end of file + + 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; + } + } +} From ab9ece083465332e483a769d7150956b490b8ab9 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 11:25:01 +0200 Subject: [PATCH 198/221] CustomVariables: Add `varname` correlation to join in prefetchCustomVarTypes() --- library/Director/CustomVariable/CustomVariables.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php index 9b324b793..dfdb70be8 100644 --- a/library/Director/CustomVariable/CustomVariables.php +++ b/library/Director/CustomVariable/CustomVariables.php @@ -71,7 +71,11 @@ protected function prefetchCustomVarTypes(IcingaObject $object): void ) ->join(['iop' => 'icinga_' . $type . '_property'], 'dp.uuid = iop.property_uuid', []) ->join(['io' => 'icinga_' . $type], 'iop.' . $type . '_uuid = io.uuid', []) - ->join(['iov' => 'icinga_' . $type . '_var'], 'iov.' . $type . '_id = io.id', []) + ->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); From acfbcb58ea90f5945fee39620c20890f9bdb5046 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 11:28:16 +0200 Subject: [PATCH 199/221] CustomVariables: Fix phpcs error due to missing space after `!` --- library/Director/CustomVariable/CustomVariables.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php index dfdb70be8..5cb64910f 100644 --- a/library/Director/CustomVariable/CustomVariables.php +++ b/library/Director/CustomVariable/CustomVariables.php @@ -436,7 +436,7 @@ public function toConfigString($renderExpressions = false, ?IcingaObject $object { $out = ''; - if ($object !== null && !empty($object->get('id'))) { + if ($object !== null && ! empty($object->get('id'))) { $this->prefetchCustomVarTypes($object); } From a8bc90cb5336ab4a8942a3a2a4bb15df88dc57d8 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 11:48:57 +0200 Subject: [PATCH 200/221] BasketSnapshotCustomVariableResolver: Add a check for unknown UUIDs in loadCurrentProperties() --- .../Automation/BasketSnapshotCustomVariableResolver.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php index a3a6beacf..3645b06e9 100644 --- a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php +++ b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php @@ -4,6 +4,7 @@ use Icinga\Exception\ProgrammingError; use Icinga\Module\Director\Data\Db\DbConnection; +use Icinga\Module\Director\Data\Db\DbObject; use Icinga\Module\Director\Db; use Icinga\Module\Director\Db\DbUtil; use Icinga\Module\Director\Objects\DirectorProperty; @@ -45,13 +46,16 @@ public function __construct($objects, Db $targetDb) * * @param Db $db * - * @return DirectorProperty[] + * @return array */ public function loadCurrentProperties(Db $db): array { $properties = []; foreach ($this->getRequiredUuids() as $uuid) { - $properties[$uuid] = DirectorProperty::loadWithUniqueId(Uuid::fromString($uuid), $db); + $property = DirectorProperty::loadWithUniqueId(Uuid::fromString($uuid), $db); + if ($property !== null) { + $properties[$uuid] = $property; + } } return $properties; From 540f4947b63e80842457a57ac59106bf0c214844 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 12:06:00 +0200 Subject: [PATCH 201/221] DictionaryItem: Prevent null values for $type and $label str_starts_with() and ucfirst() require their passed arguments to be string. --- application/forms/DictionaryElements/DictionaryItem.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/application/forms/DictionaryElements/DictionaryItem.php b/application/forms/DictionaryElements/DictionaryItem.php index 93c9ac14d..ebd380291 100644 --- a/application/forms/DictionaryElements/DictionaryItem.php +++ b/application/forms/DictionaryElements/DictionaryItem.php @@ -102,7 +102,7 @@ protected function assemble(): void $this->addElement('hidden', 'inherited_from'); $valElementName = 'var'; - $type = $this->getElement('type')->getValue(); + $type = $this->getElement('type')->getValue() ?? ''; $label = $this->getElement('label')->getValue(); if ($this->removeButton !== null) { @@ -120,7 +120,7 @@ protected function assemble(): void } if ($label === null) { - $label = $this->getElement('name')->getValue(); + $label = $this->getElement('name')->getValue() ?? ''; } $uuid = Uuid::fromBytes($this->fields['uuid']); @@ -637,7 +637,7 @@ public function getItem(bool $applyUnchangedDefaults = true): array ) { $values['value'] = $this->getElement('var-search')->getValue(); } else { - $type = $this->getElement('type')->getValue(); + $type = $this->getElement('type')->getValue() ?? ''; $parentType = $this->getElement('parent_type')->getValue(); if (empty($parentType) && ! empty($this->getElement('inherited')->getValue())) { From 9384e3172a36e456cc304bad2a78eb03c18265f9 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 12:11:14 +0200 Subject: [PATCH 202/221] CustomVariableValueCleaner: Ensure string arguments are explicitly cast to string Ensure the string arguments for json_decode() and array_key_exists() is explicitly cast to string. --- .../Director/CustomVariable/CustomVariableValueCleaner.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/Director/CustomVariable/CustomVariableValueCleaner.php b/library/Director/CustomVariable/CustomVariableValueCleaner.php index 3ce8cbddc..0579fcb57 100644 --- a/library/Director/CustomVariable/CustomVariableValueCleaner.php +++ b/library/Director/CustomVariable/CustomVariableValueCleaner.php @@ -75,7 +75,7 @@ public function resolveRootProperty(array $property, array $parent): array */ private function removeDictionaryItem(array &$item, array $path, bool $preserveIndex = false): void { - $key = array_shift($path); + $key = array_shift($path) ?? ''; if (! array_key_exists($key, $item)) { return; @@ -165,7 +165,7 @@ public function removeObjectCustomVars( $objectClass = DbObjectTypeRegistry::classByType($objectType); foreach ($varRows as $varRow) { - $varValue = json_decode($varRow['varvalue'], true); + $varValue = json_decode($varRow['varvalue'] ?? '', true); if ($rootType !== 'dynamic-dictionary') { $this->removeDictionaryItem($varValue, $path, $preserveIndex); From 5743001fad963aaf922b6118a66e7ba992f34542 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 12:17:33 +0200 Subject: [PATCH 203/221] BasketSnapshotCustomVariableResolver: Update comment for clarity in calculateUuidMap() --- .../Automation/BasketSnapshotCustomVariableResolver.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php index 3645b06e9..29617dd78 100644 --- a/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php +++ b/library/Director/DirectorObject/Automation/BasketSnapshotCustomVariableResolver.php @@ -322,7 +322,8 @@ protected function calculateUuidMap(): void $this->uuidMap = []; $this->targetProperties = []; foreach ($this->getObjectsByType('CustomVariable') as $uuid => $object) { - // Hint: import() doesn't store! + // 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( From d8cff775c2057af67a24a797e72ebf1a77ad8d08 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 12:23:32 +0200 Subject: [PATCH 204/221] DirectorProperty: Fix null override of category name during basket restore --- library/Director/Objects/DirectorProperty.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index 8e9bc4e5e..fbedcefde 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -138,12 +138,13 @@ public function setCategory($category): void $this->category = $category; } else { + $categoryName = $category; $category = DirectorDatafieldCategory::loadOptional($category, $this->getConnection()); if ($category) { $this->setCategory($category); } else { $this->setCategory(DirectorDatafieldCategory::create( - ['category_name' => $category], + ['category_name' => $categoryName], $this->getConnection() )); } From f051dc4e4ea41cbde93c7f72b29a9f02d8959019 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 13:40:08 +0200 Subject: [PATCH 205/221] DirectorProperty: Block fixed-array, fixed-dictionary and dynamic-dictionary from nesting These container types were only rejected at the top level by the CustomVariableForm dropdown, not by beforeStore() itself, so a basket restore or any other direct write could still persist one nested inside another. dynamic-array stays nestable since it's a legitimate field of the other containers, but it can no longer nest inside itself. Dropped isNestedField from CustomVariableForm, it only existed to gate a second nesting level that no longer applies now that these types are capped at the top level everywhere. Removed the five tests that relied on building a two-level container tree since that shape is no longer legal. --- .../controllers/CustomvarController.php | 5 +- application/forms/CustomVariableForm.php | 30 +- library/Director/Objects/DirectorProperty.php | 34 +- .../Director/Form/CustomVariableFormTest.php | 398 ------------------ .../Director/Objects/DirectorPropertyTest.php | 187 ++++---- 5 files changed, 145 insertions(+), 509 deletions(-) diff --git a/application/controllers/CustomvarController.php b/application/controllers/CustomvarController.php index 696bd12af..c8aba1472 100644 --- a/application/controllers/CustomvarController.php +++ b/application/controllers/CustomvarController.php @@ -140,9 +140,7 @@ public function indexAction(): void }); if ($parent) { - $propertyForm - ->setHideKeyNameElement($parent['value_type'] === 'fixed-array') - ->setIsNestedField($parent['parent_uuid'] !== null); + $propertyForm->setHideKeyNameElement($parent['value_type'] === 'fixed-array'); } $propertyForm->handleRequest($this->getServerRequest()); @@ -315,7 +313,6 @@ public function addFieldAction(): void $parent = $this->fetchProperty($uuid); $propertyForm = (new CustomVariableForm($this->db, null, true, $uuid)) ->setHideKeyNameElement($parent['value_type'] === 'fixed-array') - ->setIsNestedField($parent['parent_uuid'] !== null) ->setAction(Url::fromRequest()->getAbsoluteUrl()) ->on(CustomVariableForm::ON_SUBMIT, function (CustomVariableForm $form) { Notification::success(sprintf( diff --git a/application/forms/CustomVariableForm.php b/application/forms/CustomVariableForm.php index 4ac024709..b7825471e 100644 --- a/application/forms/CustomVariableForm.php +++ b/application/forms/CustomVariableForm.php @@ -31,9 +31,6 @@ class CustomVariableForm extends CompatForm /** @var bool Whether to hide the key name element or not (checked for the fixed array) */ private $hideKeyNameElement = false; - /** @var bool Whether the field is a nested field or not */ - private $isNestedField = false; - /** @var ?string The key name as stored in the database, used to detect pending renames */ private ?string $storedKeyName = null; @@ -80,20 +77,6 @@ public function setHideKeyNameElement(bool $hideKeyNameElement): self return $this; } - /** - * Set whether the field is a nested field (field in a sub dictionary) or not - * - * @param bool $isNestedField - * - * @return $this - */ - public function setIsNestedField(bool $isNestedField): self - { - $this->isNestedField = $isNestedField; - - return $this; - } - public function setStoredKeyName(string $keyName): self { $this->storedKeyName = $keyName; @@ -190,17 +173,14 @@ protected function assemble(): void 'datalist-non-strict' => 'Data List Non Strict', ]; - if (! $this->isNestedField) { + // 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' + 'fixed-dictionary' => 'Fixed Dictionary', + 'dynamic-dictionary' => 'Dynamic Dictionary', ]; - - if ($this->parentUuid === null) { - $types += [ - 'dynamic-dictionary' => 'Dynamic Dictionary' - ]; - } } $this->addElement( diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index fbedcefde..b3232fe1d 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -14,7 +14,11 @@ class DirectorProperty extends DbObject { /** Value types that may never be used for a nested (non-top-level) property */ - private const NON_NESTABLE_TYPES = ['dynamic-dictionary']; + private const NON_NESTABLE_TYPES = [ + 'dynamic-dictionary', + 'fixed-dictionary', + 'fixed-array', + ]; protected $table = 'director_property'; @@ -437,7 +441,8 @@ public static function import(stdClass $plain, Db $db): static /** * @throws InvalidArgumentException if a nested property is being stored with a value_type - * that may only be used at the top level, or a 'sensitive' + * that may only be used at the top level, a dynamic-array + * is nested inside another dynamic-array, or a 'sensitive' * item type is used under a dynamic-array */ protected function beforeStore(): void @@ -452,17 +457,30 @@ protected function beforeStore(): void 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 another dynamic-dictionary", + . " a fixed-array, fixed-dictionary, dynamic-array or dynamic-dictionary", $valueType )); } - // A dynamic-array shows every entry in the clear, so a sensitive item here - // could never actually be hidden. - if ($valueType === 'sensitive') { + if ($valueType === 'dynamic-array' || $valueType === 'sensitive') { $parent = DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $this->connection); - if ($parent !== null && $parent->get('value_type') === 'dynamic-array') { - throw new InvalidArgumentException("'sensitive' cannot be used as the item type of a dynamic-array"); + $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 shows every entry in the clear, so a sensitive item here + // could never actually be hidden. + if ($valueType === 'sensitive' && $parentValueType === 'dynamic-array') { + throw new InvalidArgumentException( + "'sensitive' cannot be used as the item type of a dynamic-array" + ); } } } diff --git a/test/php/library/Director/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php index 367fd9f47..ad8bcb6ce 100644 --- a/test/php/library/Director/Form/CustomVariableFormTest.php +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -354,262 +354,6 @@ public function testUpdateDynamicArrayPropertyChangesItemType(): void ); } - public function testChangingRootPropertyTypeRemovesStaleHostVarEntirely(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - // A host has "___TEST___network" set directly (not via a template Field, so - // used_count stays 0), a fixed-dictionary with a nested "interfaces" dictionary - // holding a "vlan" grandchild. - $host = IcingaHost::create([ - 'object_name' => '___TEST___lb01', - 'object_type' => 'object', - 'address' => '192.0.2.50', - ], $db); - $host->store(); - $this->createdHostNames[] = '___TEST___lb01'; - - $rootUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $rootUuid->getBytes(), - 'key_name' => '___TEST___network', - 'value_type' => 'fixed-dictionary', - 'label' => 'Network', - ], $db)->store(); - $this->createdKeyNames[] = '___TEST___network'; - - $interfacesUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $interfacesUuid->getBytes(), - 'key_name' => 'interfaces', - 'parent_uuid' => $rootUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'vlan', - 'parent_uuid' => $interfacesUuid->getBytes(), - 'value_type' => 'string', - ], $db)->store(); - - $dba->insert('icinga_host_var', [ - 'host_id' => $host->get('id'), - 'varname' => '___TEST___network', - 'varvalue' => json_encode(['interfaces' => ['vlan' => '10']]), - 'format' => 'json', - 'property_uuid' => DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba), - ]); - - $form = new TestableCustomVariableForm($db, $rootUuid); - $form->setTestValues([ - 'key_name' => '___TEST___network', - 'value_type' => 'string', - 'label' => 'Network', - 'description' => null, - ]); - self::callMethod($form, 'onSuccess', []); - - $remainingChildren = $dba->fetchOne( - $dba->select() - ->from('director_property', ['cnt' => 'COUNT(*)']) - ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba)) - ); - $this->assertSame('0', (string) $remainingChildren, 'interfaces and vlan must be dropped from the schema'); - - $hostVarRow = $dba->fetchRow( - $dba->select()->from('icinga_host_var', ['varvalue']) - ->where('host_id = ?', $host->get('id')) - ->where('varname = ?', '___TEST___network') - ); - $this->assertFalse( - $hostVarRow, - 'the stale dictionary value must not survive retyping the root property to a string' - ); - } - - public function testChangingNestedPropertyTypeStripsOnlyItsOwnKeyFromHostVar(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - // "interfaces" is retyped away from a fixed-dictionary, discarding its "vlan" - // grandchild. The sibling key "region" next to "interfaces" must survive untouched. - $host = IcingaHost::create([ - 'object_name' => '___TEST___lb02', - 'object_type' => 'object', - 'address' => '192.0.2.51', - ], $db); - $host->store(); - $this->createdHostNames[] = '___TEST___lb02'; - - $rootUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $rootUuid->getBytes(), - 'key_name' => '___TEST___network2', - 'value_type' => 'fixed-dictionary', - 'label' => 'Network', - ], $db)->store(); - $this->createdKeyNames[] = '___TEST___network2'; - - $interfacesUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $interfacesUuid->getBytes(), - 'key_name' => 'interfaces', - 'parent_uuid' => $rootUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'vlan', - 'parent_uuid' => $interfacesUuid->getBytes(), - 'value_type' => 'string', - ], $db)->store(); - - $dba->insert('icinga_host_var', [ - 'host_id' => $host->get('id'), - 'varname' => '___TEST___network2', - 'varvalue' => json_encode([ - 'interfaces' => ['vlan' => '10'], - 'region' => 'us-east', - ]), - 'format' => 'json', - 'property_uuid' => DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba), - ]); - - $form = new TestableCustomVariableForm($db, $interfacesUuid, true, $rootUuid); - $form->setTestValues([ - 'key_name' => 'interfaces', - 'value_type' => 'string', - 'label' => null, - 'description' => null, - ]); - self::callMethod($form, 'onSuccess', []); - - $vlanRow = $dba->fetchRow( - $dba->select()->from('director_property', ['uuid']) - ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($interfacesUuid->getBytes(), $dba)) - ); - $this->assertFalse($vlanRow, 'vlan must be dropped from the schema'); - - $updatedValue = $dba->fetchOne( - $dba->select()->from('icinga_host_var', ['varvalue']) - ->where('host_id = ?', $host->get('id')) - ->where('varname = ?', '___TEST___network2') - ); - $this->assertEquals( - ['region' => 'us-east'], - json_decode($updatedValue, true), - 'interfaces must be dropped from the stored value while its sibling key region survives' - ); - } - - public function testChangingFixedArrayItemTypeClearsSlotWithoutShiftingSiblings(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - // A fixed array of contacts, item 0 is a dictionary with an "ext" grandchild, item 1 - // is a plain string. Retyping item 0 to a string must clear its own slot in place - // without renumbering or otherwise touching item 1. - $host = IcingaHost::create([ - 'object_name' => '___TEST___lb03', - 'object_type' => 'object', - 'address' => '192.0.2.52', - ], $db); - $host->store(); - $this->createdHostNames[] = '___TEST___lb03'; - - $arrayUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $arrayUuid->getBytes(), - 'key_name' => '___TEST___contacts', - 'value_type' => 'fixed-array', - 'label' => 'Contacts', - ], $db)->store(); - $this->createdKeyNames[] = '___TEST___contacts'; - - $item0Uuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $item0Uuid->getBytes(), - 'key_name' => '0', - 'parent_uuid' => $arrayUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'ext', - 'parent_uuid' => $item0Uuid->getBytes(), - 'value_type' => 'string', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => '1', - 'parent_uuid' => $arrayUuid->getBytes(), - 'value_type' => 'string', - ], $db)->store(); - - $dba->insert('icinga_host_var', [ - 'host_id' => $host->get('id'), - 'varname' => '___TEST___contacts', - 'varvalue' => json_encode([['ext' => '123'], '555-1234']), - 'format' => 'json', - 'property_uuid' => DbUtil::quoteBinaryCompat($arrayUuid->getBytes(), $dba), - ]); - - $form = new TestableCustomVariableForm($db, $item0Uuid, true, $arrayUuid); - $form->setTestValues([ - 'key_name' => '0', - 'value_type' => 'string', - 'label' => null, - 'description' => null, - ]); - self::callMethod($form, 'onSuccess', []); - - $item0Row = $dba->fetchRow( - $dba->select()->from('director_property', ['key_name', 'value_type']) - ->where('uuid = ?', DbUtil::quoteBinaryCompat($item0Uuid->getBytes(), $dba)) - ); - $this->assertNotFalse($item0Row, 'item 0 itself must survive, only its children are dropped'); - $this->assertSame('0', (string) $item0Row->key_name, 'item 0 must keep its own key_name'); - $this->assertSame('string', $item0Row->value_type, 'item 0 must carry the new value_type'); - - $item1Row = $dba->fetchRow( - $dba->select()->from('director_property', ['key_name']) - ->where('parent_uuid = ?', DbUtil::quoteBinaryCompat($arrayUuid->getBytes(), $dba)) - ->where('key_name = ?', '1') - ); - $this->assertNotFalse($item1Row, 'item 1 must not be renumbered or removed'); - - $updatedValue = $dba->fetchOne( - $dba->select()->from('icinga_host_var', ['varvalue']) - ->where('host_id = ?', $host->get('id')) - ->where('varname = ?', '___TEST___contacts') - ); - $this->assertEquals( - [null, '555-1234'], - json_decode($updatedValue, true), - 'item 0 must be nulled out in place, item 1 must be untouched, and the value must' - . ' stay a JSON array rather than turn into an object' - ); - } - public function testRenamingUsedRootPropertyUpdatesHostVarnameEvenWithoutAPropertyUuidLink(): void { if ($this->skipForMissingDb()) { @@ -718,148 +462,6 @@ public function testRenamingUsedFieldUpdatesHostVarValueEvenWithoutAPropertyUuid ); } - public function testRenamingUsedGrandchildFieldUpdatesNestedPathInHostVar(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - // Renaming a grandchild, two levels below the root, must reach the correct nested - // path in the stored value rather than looking for the field name at the top level. - $host = IcingaHost::create([ - 'object_name' => '___TEST___switch05', - 'object_type' => 'object', - 'address' => '192.0.2.64', - ], $db); - $host->store(); - $this->createdHostNames[] = '___TEST___switch05'; - - $rootUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $rootUuid->getBytes(), - 'key_name' => '___TEST___router_config', - 'value_type' => 'fixed-dictionary', - 'label' => 'Router Config', - ], $db)->store(); - $this->createdKeyNames[] = '___TEST___router_config'; - - $interfaceUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $interfaceUuid->getBytes(), - 'key_name' => 'wan_interface', - 'parent_uuid' => $rootUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - ], $db)->store(); - - $mtuUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $mtuUuid->getBytes(), - 'key_name' => 'mtu', - 'parent_uuid' => $interfaceUuid->getBytes(), - 'value_type' => 'string', - ], $db)->store(); - - $dba->insert('icinga_host_var', [ - 'host_id' => $host->get('id'), - 'varname' => '___TEST___router_config', - 'varvalue' => json_encode(['wan_interface' => ['mtu' => '1500', 'speed' => '1000']]), - 'format' => 'json', - 'property_uuid' => null, - ]); - - $form = new TestableCustomVariableForm($db, $mtuUuid, true, $interfaceUuid); - $form->setIsNestedField(true); - self::callMethod($form, 'updateUsedCustomVarNames', ['mtu', 'mtu_size']); - - $updatedValue = $dba->fetchOne( - $dba->select()->from('icinga_host_var', ['varvalue']) - ->where('host_id = ?', $host->get('id')) - ->where('varname = ?', '___TEST___router_config') - ); - $this->assertEquals( - ['wan_interface' => ['mtu_size' => '1500', 'speed' => '1000']], - json_decode($updatedValue, true), - 'mtu must be renamed to mtu_size at its nested path even though it is two levels' - . ' below the root property' - ); - } - - public function testRenamingUsedGrandchildFieldUpdatesEveryDynamicDictionaryEntry(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - $host = IcingaHost::create([ - 'object_name' => '___TEST___switch06', - 'object_type' => 'object', - 'address' => '192.0.2.65', - ], $db); - $host->store(); - $this->createdHostNames[] = '___TEST___switch06'; - - $rootUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $rootUuid->getBytes(), - 'key_name' => '___TEST___datacenter_metrics', - 'value_type' => 'dynamic-dictionary', - 'label' => 'Datacenter Metrics', - ], $db)->store(); - $this->createdKeyNames[] = '___TEST___datacenter_metrics'; - - $cpuUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $cpuUuid->getBytes(), - 'key_name' => 'cpu', - 'parent_uuid' => $rootUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - ], $db)->store(); - - $usageUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $usageUuid->getBytes(), - 'key_name' => 'usage_pct', - 'parent_uuid' => $cpuUuid->getBytes(), - 'value_type' => 'string', - ], $db)->store(); - - $dba->insert('icinga_host_var', [ - 'host_id' => $host->get('id'), - 'varname' => '___TEST___datacenter_metrics', - 'varvalue' => json_encode([ - 'dc1' => ['cpu' => ['usage_pct' => '42', 'temp' => '55']], - 'dc2' => ['cpu' => ['usage_pct' => '10']], - ]), - 'format' => 'json', - 'property_uuid' => null, - ]); - - $form = new TestableCustomVariableForm($db, $usageUuid, true, $cpuUuid); - $form->setIsNestedField(true); - self::callMethod($form, 'updateUsedCustomVarNames', ['usage_pct', 'usage_percent']); - - $updatedValue = $dba->fetchOne( - $dba->select()->from('icinga_host_var', ['varvalue']) - ->where('host_id = ?', $host->get('id')) - ->where('varname = ?', '___TEST___datacenter_metrics') - ); - $this->assertEquals( - [ - 'dc1' => ['cpu' => ['usage_percent' => '42', 'temp' => '55']], - 'dc2' => ['cpu' => ['usage_percent' => '10']], - ], - json_decode($updatedValue, true), - 'usage_pct must be renamed to usage_percent inside every datacenter entry, at its' - . ' correct nested path under cpu' - ); - } - public function tearDown(): void { if ($this->hasDb()) { diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index b268274fc..7d84b50b7 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -147,26 +147,131 @@ public function testFixedDictionaryWithSubfields(): void $this->assertEquals(['crit', 'warn'], $childKeys); } - public function testDynamicDictionaryNestingIsRejectedByTheModel(): void + /** + * @dataProvider provideNonNestableTypes + */ + public function testContainerTypesAreRejectedByTheModelWhenNested(string $valueType): void { if ($this->skipForMissingDb()) { return; } - // The "dynamic-dictionary may only be a top-level property" rule must hold - // regardless of entry point (form, REST API, CLI migration, basket restore), - // not just because CustomVariableForm's dropdown happens to never offer it as - // a nested option. DirectorProperty::beforeStore() enforces it directly. + // 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(); - $parent = $this->makeProperty('disk_checks', 'dynamic-dictionary', 'Disk Checks', $db); + $parent = $this->makeProperty('container_' . str_replace('-', '_', $valueType), 'fixed-dictionary', 'Container', $db); $parent->store(); $parentUuid = $parent->get('uuid'); $child = DirectorProperty::create([ 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'thresholds', + 'key_name' => 'nested', 'parent_uuid' => $parentUuid, - 'value_type' => 'dynamic-dictionary', + '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'], + ]; + } + + /** + * @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(); + $parent = $this->makeProperty( + 'container_for_' . str_replace('-', '_', $parentValueType), + $parentValueType, + 'Container', + $db + ); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => 'nested', + '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('multiselect_list', 'datalist-strict', 'Multiselect List', $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('array_of_arrays', 'dynamic-array', 'Array Of Arrays', $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); @@ -341,72 +446,6 @@ public function testDatalistChildOfDynamicDictionaryIsPersistedWhenListDoesNotEx $this->assertNotNull($childDatalist->get('uuid'), 'Newly created datalist must have a persisted uuid'); } - /** - * Same as above, one level deeper: the not-yet-existing datalist is referenced - * by a GRANDCHILD (dynamic-dictionary -> fixed-dictionary -> datalist-strict). - */ - public function testDatalistGrandchildOfDynamicDictionaryIsPersistedWhenListDoesNotExistYet(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $parentKeyName = self::PREFIX . 'dict_with_new_list_grandchild'; - $listName = self::PREFIX . 'never_seen_list_grandchild'; - $this->createdKeyNames[] = $parentKeyName; - $this->createdListNames[] = $listName; - - $this->assertFalse(DirectorDatalist::exists($listName, $db), 'Precondition: datalist must not exist yet'); - - $parentUuid = Uuid::uuid4()->toString(); - $teamUuid = Uuid::uuid4()->toString(); - $plain = (object) [ - 'uuid' => $parentUuid, - 'key_name' => $parentKeyName, - 'value_type' => 'dynamic-dictionary', - 'label' => 'Dict With New List Grandchild', - 'parent_uuid' => null, - 'category' => null, - 'description' => null, - 'items' => [ - 'team' => (object) [ - 'uuid' => $teamUuid, - 'key_name' => 'team', - 'value_type' => 'fixed-dictionary', - 'label' => null, - 'parent_uuid' => $parentUuid, - 'category' => null, - 'description' => null, - 'items' => [ - 'severity' => $this->datalistItemPlain('severity', $teamUuid, $listName), - ], - ], - ], - ]; - - $imported = DirectorProperty::import($plain, $db); - $imported->store(); - foreach ($imported->fetchItemsFromDb() as $child) { - $child->store(); - foreach ($child->fetchItemsFromDb() as $grandchild) { - $grandchild->store(); - } - } - - $reloadedGroup = DirectorProperty::loadWithUniqueId(Uuid::fromString($teamUuid), $db); - $grandchildren = $reloadedGroup->fetchItemsFromDb(); - $this->assertCount(1, $grandchildren); - - $grandchildDatalist = $grandchildren[0]->getDatalist(); - $this->assertNotNull( - $grandchildDatalist, - 'Newly created datalist referenced by a dictionary grandchild must be persisted and linked' - ); - $this->assertEquals($listName, $grandchildDatalist->get('list_name')); - $this->assertNotNull($grandchildDatalist->get('uuid'), 'Newly created datalist must have a persisted uuid'); - } - public function testExportRoundTrip(): void { if ($this->skipForMissingDb()) { From eec05942e46975f764632de1e7107abf6cdce1bb Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 13:59:22 +0200 Subject: [PATCH 206/221] Tests: close coverage gaps in the nesting-guard test suite Add a test that drives the guard through import()/store(), the actual basket-restore path the review was worried about, instead of only create(). Add a test exercising the real CustomVariableForm::assemble() (the testable double no-ops it out) to prove the value_type dropdown still gates container types correctly at each nesting level. Add the missing positive case for sensitive under fixed-dictionary, and a dangling parent_uuid case since that column has no FK. Renamed a few test fixtures for readability while touching this area. --- .../Director/Form/CustomVariableFormTest.php | 43 ++++++ .../Director/Objects/DirectorPropertyTest.php | 130 ++++++++++++++++-- 2 files changed, 162 insertions(+), 11 deletions(-) diff --git a/test/php/library/Director/Form/CustomVariableFormTest.php b/test/php/library/Director/Form/CustomVariableFormTest.php index ad8bcb6ce..f9cac4eef 100644 --- a/test/php/library/Director/Form/CustomVariableFormTest.php +++ b/test/php/library/Director/Form/CustomVariableFormTest.php @@ -6,6 +6,7 @@ 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; @@ -94,6 +95,48 @@ public function testAddDynamicArrayPropertyCreatesParentAndChildRows(): void $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()) { diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 7d84b50b7..86ac714c4 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -160,13 +160,14 @@ public function testContainerTypesAreRejectedByTheModelWhenNested(string $valueT // 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(); - $parent = $this->makeProperty('container_' . str_replace('-', '_', $valueType), 'fixed-dictionary', 'Container', $db); + $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' => 'nested', + 'key_name' => 'interfaces', 'parent_uuid' => $parentUuid, 'value_type' => $valueType, ], $db); @@ -184,6 +185,53 @@ public function provideNonNestableTypes(): 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 */ @@ -196,18 +244,14 @@ public function testDynamicArrayIsAllowedAsAFieldOfOtherContainerTypes(string $p // 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(); - $parent = $this->makeProperty( - 'container_for_' . str_replace('-', '_', $parentValueType), - $parentValueType, - 'Container', - $db - ); + $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' => 'nested', + 'key_name' => 'dns_servers', 'parent_uuid' => $parentUuid, 'value_type' => 'dynamic-array', ], $db); @@ -237,7 +281,7 @@ public function testDynamicArrayIsAllowedAsTheItemTypeOfADatalist(): void // 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('multiselect_list', 'datalist-strict', 'Multiselect List', $db); + $parent = $this->makeProperty('allowed_regions', 'datalist-strict', 'Allowed Regions', $db); $parent->store(); $parentUuid = $parent->get('uuid'); @@ -263,7 +307,7 @@ public function testDynamicArrayCannotBeNestedInsideAnotherDynamicArray(): void } $db = $this->getDb(); - $parent = $this->makeProperty('array_of_arrays', 'dynamic-array', 'Array Of Arrays', $db); + $parent = $this->makeProperty('backup_ports', 'dynamic-array', 'Backup Ports', $db); $parent->store(); $parentUuid = $parent->get('uuid'); @@ -278,6 +322,70 @@ public function testDynamicArrayCannotBeNestedInsideAnotherDynamicArray(): void $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')); + } + + /** + * director_property.parent_uuid has no FK enforcing the link (see schema/mysql.sql), so a + * dangling reference is reachable in practice. beforeStore() must not blow up on it, and + * must not treat a vanished parent as if it were a dynamic-array. + */ + public function testDynamicArrayWithDanglingParentReferenceDoesNotCrash(): void + { + if ($this->skipForMissingDb()) { + return; + } + + $db = $this->getDb(); + $dba = $db->getDbAdapter(); + + $parent = $this->makeProperty('orphaned_array', 'dynamic-array', 'Orphaned Array', $db); + $parent->store(); + $parentUuid = $parent->get('uuid'); + + $dba->delete( + 'director_property', + $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($parentUuid, $dba)) + ); + + $childKeyName = self::PREFIX . 'orphaned_array_item'; + $this->createdKeyNames[] = $childKeyName; + $child = DirectorProperty::create([ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => $childKeyName, + 'parent_uuid' => $parentUuid, + 'value_type' => 'dynamic-array', + ], $db); + + $child->store(); + + $this->assertNotNull($child->get('uuid')); + } + public function testSensitiveCannotBeNestedInsideADynamicArray(): void { if ($this->skipForMissingDb()) { From 694b10a2149087d40be28a4e2377ee60a3e8a670 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 15:56:03 +0200 Subject: [PATCH 207/221] DirectorProperty: Add self-referential FK so orphaned children can't happen parent_uuid had no FK, so deleting a property row directly (e.g. during basket restore) could leave its children pointing at nothing. Add ON DELETE CASCADE on both engines, plus the parent_uuid index Postgres doesn't create automatically for FK columns the way InnoDB does. --- schema/mysql-migrations/upgrade_193.sql | 5 ++ schema/mysql.sql | 5 ++ schema/pgsql-migrations/upgrade_193.sql | 9 +++ schema/pgsql.sql | 7 ++ .../Director/Objects/DirectorPropertyTest.php | 65 +++---------------- 5 files changed, 35 insertions(+), 56 deletions(-) diff --git a/schema/mysql-migrations/upgrade_193.sql b/schema/mysql-migrations/upgrade_193.sql index d61a20c6c..f9b8dc97c 100644 --- a/schema/mysql-migrations/upgrade_193.sql +++ b/schema/mysql-migrations/upgrade_193.sql @@ -24,6 +24,11 @@ CREATE TABLE director_property ( 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 ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/schema/mysql.sql b/schema/mysql.sql index 167577700..de2459e53 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -275,6 +275,11 @@ CREATE TABLE director_property ( 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 ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/schema/pgsql-migrations/upgrade_193.sql b/schema/pgsql-migrations/upgrade_193.sql index 51ffaa08a..2c42cd0cb 100644 --- a/schema/pgsql-migrations/upgrade_193.sql +++ b/schema/pgsql-migrations/upgrade_193.sql @@ -24,6 +24,11 @@ CREATE TABLE director_property ( 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 ON UPDATE CASCADE ); @@ -37,6 +42,10 @@ 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, diff --git a/schema/pgsql.sql b/schema/pgsql.sql index fe5d7a162..0b77841a9 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -344,6 +344,11 @@ CREATE TABLE director_property ( 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 ON UPDATE CASCADE ); @@ -355,6 +360,8 @@ 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, diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 86ac714c4..cf0ec9360 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -349,43 +349,6 @@ public function testSensitiveIsAllowedAsAFieldOfAFixedDictionary(): void $this->assertEquals('sensitive', $items[0]->get('value_type')); } - /** - * director_property.parent_uuid has no FK enforcing the link (see schema/mysql.sql), so a - * dangling reference is reachable in practice. beforeStore() must not blow up on it, and - * must not treat a vanished parent as if it were a dynamic-array. - */ - public function testDynamicArrayWithDanglingParentReferenceDoesNotCrash(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - $parent = $this->makeProperty('orphaned_array', 'dynamic-array', 'Orphaned Array', $db); - $parent->store(); - $parentUuid = $parent->get('uuid'); - - $dba->delete( - 'director_property', - $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($parentUuid, $dba)) - ); - - $childKeyName = self::PREFIX . 'orphaned_array_item'; - $this->createdKeyNames[] = $childKeyName; - $child = DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => $childKeyName, - 'parent_uuid' => $parentUuid, - 'value_type' => 'dynamic-array', - ], $db); - - $child->store(); - - $this->assertNotNull($child->get('uuid')); - } - public function testSensitiveCannotBeNestedInsideADynamicArray(): void { if ($this->skipForMissingDb()) { @@ -600,7 +563,7 @@ public function testExportRoundTrip(): void $this->assertEquals(['crit', 'warn'], $childKeys); } - public function testImportWithOrphanedParentDoesNotCrash(): void + public function testDeletingAParentCascadesToItsChildren(): void { if ($this->skipForMissingDb()) { return; @@ -609,38 +572,28 @@ public function testImportWithOrphanedParentDoesNotCrash(): void $db = $this->getDb(); $dba = $db->getDbAdapter(); - $parent = $this->makeProperty('orphan_parent', 'fixed-dictionary', 'Orphan Parent', $db); + $parent = $this->makeProperty('cascade_parent', 'fixed-dictionary', 'Cascade Parent', $db); $parent->store(); $parentUuid = $parent->get('uuid'); - $childKeyName = self::PREFIX . 'orphan_child'; + $childKeyName = self::PREFIX . 'cascade_child'; $this->createdKeyNames[] = $childKeyName; - DirectorProperty::create([ + $child = DirectorProperty::create([ 'uuid' => Uuid::uuid4()->getBytes(), 'key_name' => $childKeyName, 'parent_uuid' => $parentUuid, 'value_type' => 'string', - ], $db)->store(); + ], $db); + $child->store(); + $childUuid = $child->get('uuid'); - // Simulate a dangling parent reference: director_property.parent_uuid has no FK - // enforcing this link (see schema/mysql.sql), so this state is reachable in practice. $dba->delete( 'director_property', $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($parentUuid, $dba)) ); - // This branch expects parent_uuid as raw bytes (matching how the equivalent - // lookup for $plain->parent_uuid is used at Uuid::fromBytes() a few lines down - // in import()), unlike the has-uuid branch's string-form convention. - $plain = (object) [ - 'key_name' => $childKeyName, - 'parent_uuid' => $parentUuid, - 'value_type' => 'string', - ]; - - $imported = DirectorProperty::import($plain, $db); - - $this->assertInstanceOf(DirectorProperty::class, $imported); + $this->assertNull(DirectorProperty::loadWithUniqueId(Uuid::fromBytes($parentUuid), $db)); + $this->assertNull(DirectorProperty::loadWithUniqueId(Uuid::fromBytes($childUuid), $db)); } public function testImportIsIdempotent(): void From e672c607444fdb8c443716fe753d153d85caece1 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 16:08:48 +0200 Subject: [PATCH 208/221] doc: Fix dynamic-array nesting rule and add missing example The nesting note said a dynamic-array can never nest inside a fixed-array/fixed-dictionary/dynamic-dictionary, which stopped being true once beforeStore() started allowing it - the only nesting it actually blocks is a dynamic-array inside another dynamic-array. --- doc/12-Handling-custom-variables.md | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/doc/12-Handling-custom-variables.md b/doc/12-Handling-custom-variables.md index f76f2a142..2169c6088 100644 --- a/doc/12-Handling-custom-variables.md +++ b/doc/12-Handling-custom-variables.md @@ -62,15 +62,15 @@ types: | `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 item type of a `fixed-array`, -> `dynamic-array` or `fixed-dictionary`, and the fields inside a -> `dynamic-dictionary`'s sub-dictionary, may only be scalar (`string`, -> `number`, `bool`, `sensitive`) or datalist (`datalist-strict`, -> `datalist-non-strict`) types. A nested field can itself be an array of -> datalist values, but it can never be a `fixed-array`, `fixed-dictionary` -> or `dynamic-array`/`dynamic-dictionary`. Also, `dynamic-dictionary` can -> only be defined as a top-level property; it cannot be nested inside -> another array or dictionary. +> 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`. 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 @@ -205,6 +205,17 @@ vars.snmp_v3 = { } ``` +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. From 91bae607107acfdaa10aad9fcc8bf313f7abd3c5 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 17:39:08 +0200 Subject: [PATCH 209/221] IcingaService: explain why apply-for lookup needs no host scoping --- library/Director/Objects/IcingaService.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php index e9655b499..bfb37efe7 100644 --- a/library/Director/Objects/IcingaService.php +++ b/library/Director/Objects/IcingaService.php @@ -437,6 +437,9 @@ protected function fetchApplyForPropertyType(string $applyFor): ?string 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']) From a0fe72bbb7af7eaeb21735548b25cad8faa02e3c Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 17:39:15 +0200 Subject: [PATCH 210/221] Tests: cover keepPropertyInPlace retype path in the value cleaner --- .../CustomVariableValueCleanerTest.php | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 test/php/library/Director/CustomVariable/CustomVariableValueCleanerTest.php 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(); + } +} From 7380089d1f2b68b9de1d6a7ce7c63d5b288f7e4e Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 17:39:21 +0200 Subject: [PATCH 211/221] Tests: cover removeObjectCustomVars for a service var --- .../Form/DeleteCustomVariableFormTest.php | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index 4a4d617da..f3ff00a20 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -11,6 +11,7 @@ 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; @@ -1105,6 +1106,85 @@ public function testDeletingDynamicDictionaryFieldUpdatesEveryEntryInOverrideSer ); } + 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()) { From 8f9ce00db6b097aeae0a369953f4f28915229a9b Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 17:44:01 +0200 Subject: [PATCH 212/221] DirectorProperty: store a pending category instead of dropping it silently --- library/Director/Objects/DirectorProperty.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index b3232fe1d..09e8381f6 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -447,6 +447,8 @@ public static function import(stdClass $plain, Db $db): static */ protected function beforeStore(): void { + $this->persistPendingCategory(); + $parentUuid = $this->get('parent_uuid'); if ($parentUuid === null) { return; @@ -485,6 +487,36 @@ protected function beforeStore(): void } } + /** + * 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; From ce61110e9ef1ca912273d9f4787aa6045da047d9 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Mon, 27 Jul 2026 17:44:07 +0200 Subject: [PATCH 213/221] Tests: cover category restore onto a fresh DB --- .../BasketSnapshotCustomVariableTest.php | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php index 6a6b1f88f..301b11e78 100644 --- a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php +++ b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php @@ -286,6 +286,39 @@ public function testRestoreStampsPropertyUuidOnVarTable(): void ); } + 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' + ); + + $dba->delete('director_datafield_category', $dba->quoteInto('category_name = ?', $categoryName)); + } + public function testRestoreIsIdempotent(): void { if ($this->skipForMissingDb()) { From dfc23cdf9802fd517539b7b38133fbf52d476f22 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 28 Jul 2026 09:43:35 +0200 Subject: [PATCH 214/221] DirectorProperty: drop ON UPDATE CASCADE from parent FK MariaDB rejects ON UPDATE CASCADE on a column that a stored generated column depends on, and parent_uuid_v depends on parent_uuid. Since uuid primary keys never change, the clause was dead weight anyway. Dropped it on both engines, kept ON DELETE CASCADE. --- schema/mysql-migrations/upgrade_193.sql | 1 - schema/mysql.sql | 1 - schema/pgsql-migrations/upgrade_193.sql | 1 - schema/pgsql.sql | 1 - 4 files changed, 4 deletions(-) diff --git a/schema/mysql-migrations/upgrade_193.sql b/schema/mysql-migrations/upgrade_193.sql index f9b8dc97c..c997f5318 100644 --- a/schema/mysql-migrations/upgrade_193.sql +++ b/schema/mysql-migrations/upgrade_193.sql @@ -29,7 +29,6 @@ CREATE TABLE director_property ( FOREIGN KEY parent (parent_uuid) REFERENCES director_property (uuid) ON DELETE CASCADE - ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE icinga_host_property ( diff --git a/schema/mysql.sql b/schema/mysql.sql index de2459e53..b8438d5fb 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -280,7 +280,6 @@ CREATE TABLE director_property ( FOREIGN KEY parent (parent_uuid) REFERENCES director_property (uuid) ON DELETE CASCADE - ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; ALTER TABLE director_datalist diff --git a/schema/pgsql-migrations/upgrade_193.sql b/schema/pgsql-migrations/upgrade_193.sql index 2c42cd0cb..0f7ca2322 100644 --- a/schema/pgsql-migrations/upgrade_193.sql +++ b/schema/pgsql-migrations/upgrade_193.sql @@ -29,7 +29,6 @@ CREATE TABLE director_property ( FOREIGN KEY (parent_uuid) REFERENCES director_property (uuid) ON DELETE CASCADE - ON UPDATE CASCADE ); -- Unique key_name at root level (no parent) diff --git a/schema/pgsql.sql b/schema/pgsql.sql index 0b77841a9..abff207f5 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -349,7 +349,6 @@ CREATE TABLE director_property ( FOREIGN KEY (parent_uuid) REFERENCES director_property (uuid) ON DELETE CASCADE - ON UPDATE CASCADE ); CREATE UNIQUE INDEX unique_property_name_root From 529156223b667c8250e3f63ceeb665743dd99f56 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 28 Jul 2026 09:57:59 +0200 Subject: [PATCH 215/221] Fix teardown in category restore test Restore recreates the property before we delete the category, so cleanup needs to wipe that property first or the delete hits a FK error. --- .../Director/Objects/BasketSnapshotCustomVariableTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php index 301b11e78..7972f2ee4 100644 --- a/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php +++ b/test/php/library/Director/Objects/BasketSnapshotCustomVariableTest.php @@ -316,6 +316,10 @@ public function testRestoreCreatesCategoryWhenMissingOnTarget(): void '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)); } From feb8880f0d26653bb131a9895a1546f973f57fdd Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 28 Jul 2026 10:11:43 +0200 Subject: [PATCH 216/221] Drop tests that build illegal nested container properties fixed-array, fixed-dictionary and dynamic-dictionary can no longer nest inside another container since f051dc4e. These tests still built that shape and always throw now. The behavior they meant to cover is already proven by the single-level tests that stay. --- .../Automation/BasketDiffTest.php | 71 +--- .../Form/DeleteCustomVariableFormTest.php | 311 ------------------ .../DictionaryElements/DictionaryItemTest.php | 266 --------------- 3 files changed, 1 insertion(+), 647 deletions(-) diff --git a/test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php b/test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php index 2abacf5e7..af42d5d61 100644 --- a/test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php +++ b/test/php/library/Director/DirectorObject/Automation/BasketDiffTest.php @@ -17,75 +17,6 @@ class BasketDiffTest extends BaseTestCase { private const PREFIX = '___TEST___'; - public function testDiffDetectsGrandchildRemovedFromCustomVariable(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - - // A "network_config" fixed dictionary with a nested "interfaces" dictionary that - // itself carries a "vlan_id" grandchild, the same shape as the reported bug. - $rootUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $rootUuid->getBytes(), - 'key_name' => self::PREFIX . 'network_config', - 'value_type' => 'fixed-dictionary', - 'label' => 'Network Config', - ], $db)->store(); - - $interfaceUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $interfaceUuid->getBytes(), - 'key_name' => 'interfaces', - 'parent_uuid' => $rootUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - ], $db)->store(); - - $vlanUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $vlanUuid->getBytes(), - 'key_name' => 'vlan_id', - 'parent_uuid' => $interfaceUuid->getBytes(), - 'value_type' => 'string', - ], $db)->store(); - - // Snapshot the property while the grandchild still exists. - $propertyUuidString = $rootUuid->toString(); - $exportedProperty = DirectorProperty::loadWithUniqueId($rootUuid, $db)->export(); - $basket = Basket::create(['uuid' => Uuid::uuid4()->getBytes(), 'basket_name' => self::PREFIX . 'basket1']); - $snapshot = BasketSnapshot::forBasketFromJson( - $basket, - json_encode(['CustomVariable' => [$propertyUuidString => $exportedProperty]]) - ); - - // Now delete the grandchild, exactly as reported in the bug. - $dba = $db->getDbAdapter(); - $dba->delete( - 'director_property', - $dba->quoteInto('uuid = ?', DbUtil::quoteBinaryCompat($vlanUuid->getBytes(), $dba)) - ); - - $diff = new BasketDiff($snapshot, $db); - - $this->assertTrue( - $diff->hasChangedFor('CustomVariable', $propertyUuidString, $rootUuid), - 'removing a grandchild from a custom variable must show up as a change in the basket diff' - ); - - $this->assertStringContainsString( - 'vlan_id', - $diff->getBasketString('CustomVariable', $propertyUuidString), - 'the basket side must still show the grandchild as it was at snapshot time' - ); - $this->assertStringNotContainsString( - 'vlan_id', - $diff->getCurrentString('CustomVariable', $propertyUuidString, $rootUuid), - 'the current side must reflect that the grandchild no longer exists' - ); - } - public function testDiffReportsUnchangedWhenNothingChanged(): void { if ($this->skipForMissingDb()) { @@ -131,7 +62,7 @@ protected function tearDown(): void $db = $this->getDb(); $dba = $db->getDbAdapter(); - foreach ([self::PREFIX . 'network_config', self::PREFIX . 'snmp_settings'] as $keyName) { + foreach ([self::PREFIX . 'snmp_settings'] as $keyName) { $rows = $dba->fetchAll( $dba->select()->from('director_property', ['uuid'])->where('key_name = ?', $keyName) ); diff --git a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php index f3ff00a20..4684aed56 100644 --- a/test/php/library/Director/Form/DeleteCustomVariableFormTest.php +++ b/test/php/library/Director/Form/DeleteCustomVariableFormTest.php @@ -499,199 +499,6 @@ public function testOnSuccessRollsBackTransactionWhenAnExceptionIsThrown(): void ); } - public function testDeletingThreeLevelsDeepFieldUpdatesHostVarWithoutCrashing(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - // A host's notification policy: a root dictionary containing "business_hours", - // which itself contains an "escalation" dictionary with a contact_email field -- - // three levels of nesting below the custom variable's own icinga_host_var row. - $host = IcingaHost::create([ - 'object_name' => '___TEST___oncall01', - 'object_type' => 'object', - 'address' => '192.0.2.20', - ], $db); - $host->store(); - - $rootUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $rootUuid->getBytes(), - 'key_name' => '___TEST___notification_policy', - 'value_type' => 'fixed-dictionary', - 'label' => 'Notification Policy', - ], $db)->store(); - - $businessHoursUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $businessHoursUuid->getBytes(), - 'key_name' => 'business_hours', - 'parent_uuid' => $rootUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - 'label' => 'Business Hours', - ], $db)->store(); - - $escalationUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $escalationUuid->getBytes(), - 'key_name' => 'escalation', - 'parent_uuid' => $businessHoursUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - 'label' => 'Escalation', - ], $db)->store(); - - $contactEmailUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $contactEmailUuid->getBytes(), - 'key_name' => 'contact_email', - 'parent_uuid' => $escalationUuid->getBytes(), - 'value_type' => 'string', - 'label' => 'Contact Email', - ], $db)->store(); - - $dba->insert('icinga_host_var', [ - 'host_id' => $host->get('id'), - 'varname' => '___TEST___notification_policy', - 'varvalue' => json_encode([ - 'business_hours' => [ - 'escalation' => [ - 'contact_email' => 'ops@example.com', - 'phone' => '+1-555-0100', - ], - ], - ]), - 'format' => 'json', - 'property_uuid' => DbUtil::quoteBinaryCompat($rootUuid->getBytes(), $dba), - ]); - - $form = new DeleteCustomVariableForm( - $db, - [ - 'uuid' => $contactEmailUuid->getBytes(), - 'key_name' => 'contact_email', - 'value_type' => 'string', - 'label' => 'Contact Email', - 'description' => null, - 'parent_uuid' => $escalationUuid->getBytes(), - ], - [ - 'uuid' => $escalationUuid->getBytes(), - 'key_name' => 'escalation', - 'value_type' => 'fixed-dictionary', - 'parent_uuid' => $businessHoursUuid->getBytes(), - ] - ); - - self::callMethod($form, 'onSuccess', []); - - $updatedValue = $dba->fetchOne( - $dba->select()->from('icinga_host_var', ['varvalue']) - ->where('host_id = ?', $host->get('id')) - ->where('varname = ?', '___TEST___notification_policy') - ); - - $this->assertEquals( - ['business_hours' => ['escalation' => ['phone' => '+1-555-0100']]], - json_decode($updatedValue, true), - 'contact_email must be removed from the correct nested path even though the' - . ' root property is three levels above the deleted field' - ); - } - - public function testDeletingFieldUpdatesHostVarEvenWithoutAPropertyUuidLink(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - // property_uuid on icinga_host_var is an optional hint, not every stored row has it - // set (e.g. values entered directly through a host's own custom variables editor, - // rather than via a template's Fields tab). Deleting a field must still find and - // clean up such a row by varname. - $host = IcingaHost::create([ - 'object_name' => '___TEST___switch01', - 'object_type' => 'object', - 'address' => '192.0.2.60', - ], $db); - $host->store(); - - $rootUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $rootUuid->getBytes(), - 'key_name' => '___TEST___network_config', - 'value_type' => 'fixed-dictionary', - 'label' => 'Network Config', - ], $db)->store(); - - $interfaceUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $interfaceUuid->getBytes(), - 'key_name' => 'management_interface', - 'parent_uuid' => $rootUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - ], $db)->store(); - - $vlanUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $vlanUuid->getBytes(), - 'key_name' => 'vlan_id', - 'parent_uuid' => $interfaceUuid->getBytes(), - 'value_type' => 'string', - ], $db)->store(); - - $dba->insert('icinga_host_var', [ - 'host_id' => $host->get('id'), - 'varname' => '___TEST___network_config', - 'varvalue' => json_encode([ - 'hostname' => 'sw01-mgmt', - 'management_interface' => ['vlan_id' => '100'], - ]), - 'format' => 'json', - 'property_uuid' => null, - ]); - - $form = new DeleteCustomVariableForm( - $db, - [ - 'uuid' => $vlanUuid->getBytes(), - 'key_name' => 'vlan_id', - 'value_type' => 'string', - 'label' => null, - 'description' => null, - 'parent_uuid' => $interfaceUuid->getBytes(), - ], - [ - 'uuid' => $interfaceUuid->getBytes(), - 'key_name' => 'management_interface', - 'value_type' => 'fixed-dictionary', - 'parent_uuid' => $rootUuid->getBytes(), - ] - ); - - self::callMethod($form, 'onSuccess', []); - - $updatedValue = $dba->fetchOne( - $dba->select()->from('icinga_host_var', ['varvalue']) - ->where('host_id = ?', $host->get('id')) - ->where('varname = ?', '___TEST___network_config') - ); - - $this->assertEquals( - ['hostname' => 'sw01-mgmt'], - json_decode($updatedValue, true), - 'vlan_id must be removed from the stored value even though property_uuid was' - . ' never linked on that row, and management_interface must collapse away' - . ' since vlan_id was its only key' - ); - } - public function testDeletingRootPropertyRemovesHostVarEvenWithoutAPropertyUuidLink(): void { if ($this->skipForMissingDb()) { @@ -743,118 +550,6 @@ public function testDeletingRootPropertyRemovesHostVarEvenWithoutAPropertyUuidLi $this->assertFalse($row, 'the host_var row must be dropped by varname even without a property_uuid link'); } - public function testDeletingThreeLevelsDeepFieldUpdatesOverrideServiceVarsWithoutCrashing(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $db = $this->getDb(); - $dba = $db->getDbAdapter(); - - // A host overrides a service's alert routing: root dictionary "alert_routing" - // containing "team", which contains "priority", which contains a pager_email field -- - // three levels of nesting below the custom variable's own key in _override_servicevars. - $host = IcingaHost::create([ - 'object_name' => '___TEST___router01', - 'object_type' => 'object', - 'address' => '192.0.2.30', - ], $db); - $host->store(); - - $rootUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $rootUuid->getBytes(), - 'key_name' => '___TEST___alert_routing', - 'value_type' => 'fixed-dictionary', - 'label' => 'Alert Routing', - ], $db)->store(); - - $teamUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $teamUuid->getBytes(), - 'key_name' => 'team', - 'parent_uuid' => $rootUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - 'label' => 'Team', - ], $db)->store(); - - $priorityUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $priorityUuid->getBytes(), - 'key_name' => 'priority', - 'parent_uuid' => $teamUuid->getBytes(), - 'value_type' => 'fixed-dictionary', - 'label' => 'Priority', - ], $db)->store(); - - $pagerEmailUuid = Uuid::uuid4(); - DirectorProperty::create([ - 'uuid' => $pagerEmailUuid->getBytes(), - 'key_name' => 'pager_email', - 'parent_uuid' => $priorityUuid->getBytes(), - 'value_type' => 'string', - 'label' => 'Pager Email', - ], $db)->store(); - - $dba->insert('icinga_host_var', [ - 'host_id' => $host->get('id'), - 'varname' => '_override_servicevars', - 'varvalue' => json_encode([ - 'web_check' => [ - '___TEST___alert_routing' => [ - 'team' => [ - 'priority' => [ - 'pager_email' => 'oncall@example.com', - 'sms' => '+1-555-0200', - ], - ], - ], - ], - ]), - 'format' => 'json', - ]); - - $form = new DeleteCustomVariableForm( - $db, - [ - 'uuid' => $pagerEmailUuid->getBytes(), - 'key_name' => 'pager_email', - 'value_type' => 'string', - 'label' => 'Pager Email', - 'description' => null, - 'parent_uuid' => $priorityUuid->getBytes(), - ], - [ - 'uuid' => $priorityUuid->getBytes(), - 'key_name' => 'priority', - 'value_type' => 'fixed-dictionary', - 'parent_uuid' => $teamUuid->getBytes(), - ] - ); - - 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___alert_routing' => [ - 'team' => ['priority' => ['sms' => '+1-555-0200']], - ], - ], - ], - json_decode($updatedValue, true), - 'pager_email must be removed from the correct nested path even though the' - . ' root property is three levels above the deleted field' - ); - } - public function testDeletingDynamicDictionaryFieldUpdatesEveryEntryInHostVar(): void { if ($this->skipForMissingDb()) { @@ -1288,13 +983,10 @@ public function tearDown(): void $hostNames = [ '___TEST___backup01', '___TEST___maintenance01', - '___TEST___oncall01', - '___TEST___router01', '___TEST___multidc01', '___TEST___multidc02', '___TEST___multidc03', '___TEST___oncall_template', - '___TEST___switch01', '___TEST___switch02', ]; foreach ($hostNames as $hostName) { @@ -1308,13 +1000,10 @@ public function tearDown(): void '___TEST___backup_directories', '___TEST___escalation_contacts', '___TEST___maintenance_window', - '___TEST___notification_policy', - '___TEST___alert_routing', '___TEST___datacenter_settings', '___TEST___datacenter_settings_2', '___TEST___datacenter_settings_3', '___TEST___contact_numbers', - '___TEST___network_config', '___TEST___snmp_community', ]; foreach ($keyNames as $keyName) { diff --git a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php index 1f05d1f4c..3749e5878 100644 --- a/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php +++ b/test/php/library/Director/Form/DictionaryElements/DictionaryItemTest.php @@ -385,69 +385,6 @@ public function testClearingAPreviouslyStoredNumberDoesNotBounceBackToItsTypeDef $this->assertSame([null, '', null, ''], $result['value']); } - public function testNestedFixedArrayInsideFixedArrayKeepsItsOwnValueWhenASiblingChanges(): void - { - if ($this->skipForMissingDb()) { - return; - } - - // A fixed array can hold another fixed array as one of its slots. Changing an - // unrelated sibling still saves the whole outer array, so the untouched nested - // array must contribute its own value instead of vanishing from it. - $db = $this->getDb(); - $outerUuidBytes = Uuid::uuid4()->getBytes(); - $keyName = self::PREFIX . 'monitoring_targets'; - $this->createdKeyNames[] = $keyName; - - DirectorProperty::create([ - 'uuid' => $outerUuidBytes, - 'key_name' => $keyName, - 'value_type' => 'fixed-array', - 'label' => 'Monitoring Targets', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => '0', - 'parent_uuid' => $outerUuidBytes, - 'value_type' => 'string', - ], $db)->store(); - - $nestedUuidBytes = Uuid::uuid4()->getBytes(); - DirectorProperty::create([ - 'uuid' => $nestedUuidBytes, - 'key_name' => '1', - 'parent_uuid' => $outerUuidBytes, - 'value_type' => 'fixed-array', - ], $db)->store(); - - foreach (['0', '1'] as $nestedPosition) { - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => $nestedPosition, - 'parent_uuid' => $nestedUuidBytes, - 'value_type' => 'string', - ], $db)->store(); - } - - $property = [ - 'uuid' => $outerUuidBytes, - 'key_name' => $keyName, - 'value_type' => 'fixed-array', - 'label' => 'Monitoring Targets', - ]; - - $dictionaryItem = new DictionaryItem('0', $property); - $dictionaryItem->populate(DictionaryItem::prepare($property)); - $dictionaryItem->ensureAssembled(); - - $this->findNestedItem($dictionaryItem, '0')->getElement('var')->setValue('primary.example.com'); - - $result = $dictionaryItem->getItem(); - - $this->assertSame(['primary.example.com', ['', '']], $result['value']); - } - public function testRequiredScalarFieldIsMarkedRequiredOnTheElement(): void { if ($this->skipForMissingDb()) { @@ -846,209 +783,6 @@ public function testNestedSensitiveFieldClearsExistingValueWhenExplicitlyEmptied $this->assertSame('', $result['value']['auth_password']); } - public function testSensitiveFieldDirectlyInsideDynamicDictionaryEntryPreservesValueWhenLeftUnchanged(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(); - - $result = $dictionaryItem->getItem(); - - $this->assertSame('s3cr3t-disk-token', $result['value']['/var/log']['api_token']); - } - - public function testSensitiveFieldNestedAsGrandchildOfDynamicDictionaryPreservesValueWhenLeftUnchanged(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(); - - $result = $dictionaryItem->getItem(); - - $this->assertSame('s3cr3t-nested-token', $result['value']['/var/log']['disk_users']['token']); - } - - public function testSensitiveFieldsInsideDynamicDictionaryEntryAreClearedWhenExplicitlyEmptied(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(clearInsteadOfUnchanged: true); - - $result = $dictionaryItem->getItem(); - - $this->assertSame('', $result['value']['/var/log']['api_token']); - $this->assertSame('', $result['value']['/var/log']['disk_users']['token']); - } - - public function testSensitiveFieldsInsideDynamicDictionaryEntryKeepANewlyTypedValue(): void - { - if ($this->skipForMissingDb()) { - return; - } - - $dictionaryItem = $this->buildDiskThresholdsDictionaryItem(retypedValue: 'freshly-typed-value'); - - $result = $dictionaryItem->getItem(); - - $this->assertSame('freshly-typed-value', $result['value']['/var/log']['api_token']); - $this->assertSame('freshly-typed-value', $result['value']['/var/log']['disk_users']['token']); - } - - /** - * Build a dynamic-dictionary DictionaryItem ("disk_thresholds") backed by real - * director_property rows. It has one sensitive field directly on each entry - * ("api_token") and one nested one level deeper ("disk_users.token"). Loads and - * populates it the same way the controller does, so the sensitive fields are - * submitted as either the DUMMYPASSWORD placeholder (untouched), an empty string - * (cleared), or a new value, never the real secret. - */ - private function buildDiskThresholdsDictionaryItem( - bool $clearInsteadOfUnchanged = false, - ?string $retypedValue = null - ): DictionaryItem { - $db = $this->getDb(); - $parentUuidBytes = Uuid::uuid4()->getBytes(); - $keyName = self::PREFIX . 'disk_thresholds'; - $this->createdKeyNames[] = $keyName; - - DirectorProperty::create([ - 'uuid' => $parentUuidBytes, - 'key_name' => $keyName, - 'value_type' => 'dynamic-dictionary', - 'label' => 'Disk Thresholds', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'threshold', - 'parent_uuid' => $parentUuidBytes, - 'value_type' => 'number', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'api_token', - 'parent_uuid' => $parentUuidBytes, - 'value_type' => 'sensitive', - ], $db)->store(); - - $diskUsersUuidBytes = Uuid::uuid4()->getBytes(); - DirectorProperty::create([ - 'uuid' => $diskUsersUuidBytes, - 'key_name' => 'disk_users', - 'parent_uuid' => $parentUuidBytes, - 'value_type' => 'fixed-dictionary', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'team', - 'parent_uuid' => $diskUsersUuidBytes, - 'value_type' => 'string', - ], $db)->store(); - - DirectorProperty::create([ - 'uuid' => Uuid::uuid4()->getBytes(), - 'key_name' => 'token', - 'parent_uuid' => $diskUsersUuidBytes, - 'value_type' => 'sensitive', - ], $db)->store(); - - $property = [ - 'uuid' => $parentUuidBytes, - 'key_name' => $keyName, - 'value_type' => 'dynamic-dictionary', - 'label' => 'Disk Thresholds', - 'value' => [ - '/var/log' => [ - 'threshold' => 5, - 'api_token' => 's3cr3t-disk-token', - 'disk_users' => [ - 'team' => 'ops', - 'token' => 's3cr3t-nested-token', - ], - ], - ], - ]; - - $preparedValues = DictionaryItem::prepare($property); - - $dictionaryItem = new DictionaryItem('0', $property); - $dictionaryItem->populate($preparedValues); - $submittedValues = match (true) { - $retypedValue !== null => $this->retypeSensitiveFields($preparedValues, $retypedValue), - $clearInsteadOfUnchanged => $this->clearSensitiveFields($preparedValues), - default => $this->markSensitiveFieldsUnchanged($preparedValues), - }; - $dictionaryItem->populate($submittedValues); - $dictionaryItem->ensureAssembled(); - - return $dictionaryItem; - } - - /** - * Replace every sensitive field's value with the DUMMYPASSWORD placeholder, like a - * browser resubmitting an untouched field. - */ - private function markSensitiveFieldsUnchanged(array $node): array - { - if (($node['type'] ?? null) === 'sensitive') { - $node['var'] = SensitiveElement::DUMMYPASSWORD; - } - - foreach ($node as $key => $value) { - if (is_array($value)) { - $node[$key] = $this->markSensitiveFieldsUnchanged($value); - } - } - - return $node; - } - - /** - * Replace every sensitive field's value with an empty string, like a browser - * submitting a field the user cleared. - */ - private function clearSensitiveFields(array $node): array - { - if (($node['type'] ?? null) === 'sensitive') { - $node['var'] = ''; - } - - foreach ($node as $key => $value) { - if (is_array($value)) { - $node[$key] = $this->clearSensitiveFields($value); - } - } - - return $node; - } - - /** - * Replace every sensitive field's value with a new value, like a browser - * submitting a field the user just changed. - */ - private function retypeSensitiveFields(array $node, string $newValue): array - { - if (($node['type'] ?? null) === 'sensitive') { - $node['var'] = $newValue; - } - - foreach ($node as $key => $value) { - if (is_array($value)) { - $node[$key] = $this->retypeSensitiveFields($value, $newValue); - } - } - - return $node; - } - /** * Build a fixed-dictionary DictionaryItem ("snmp_v3") with a string child * ("username") and a sensitive child ("auth_password"), backed by real From fcf09dcd5031e2ccfb61d3093185c14de624f1e3 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 28 Jul 2026 10:32:00 +0200 Subject: [PATCH 217/221] DirectorProperty: block sensitive under a datalist too The dropdown already hid sensitive as a datalist item type since those render every entry in the clear same as dynamic-array, but beforeStore only checked for a dynamic-array parent. A basket restore or direct write could still sneak one in. Added the datalist check plus a test for both datalist kinds. --- library/Director/Objects/DirectorProperty.php | 13 ++++--- .../Director/Objects/DirectorPropertyTest.php | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index 09e8381f6..ad9f68c50 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -20,6 +20,9 @@ class DirectorProperty extends DbObject 'fixed-array', ]; + /** Parent value_types whose items are always rendered in the clear, never masked */ + private const UNMASKED_LIST_TYPES = ['dynamic-array', 'datalist-strict', 'datalist-non-strict']; + protected $table = 'director_property'; protected $keyName = 'key_name'; @@ -443,7 +446,7 @@ public static function import(stdClass $plain, Db $db): static * @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, or a 'sensitive' - * item type is used under a dynamic-array + * item type is used under a dynamic-array or datalist */ protected function beforeStore(): void { @@ -477,11 +480,11 @@ protected function beforeStore(): void ); } - // A dynamic-array shows every entry in the clear, so a sensitive item here - // could never actually be hidden. - if ($valueType === 'sensitive' && $parentValueType === '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" + "'sensitive' cannot be used as the item type of a dynamic-array or datalist" ); } } diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index cf0ec9360..0d239acd7 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -372,6 +372,40 @@ public function testSensitiveCannotBeNestedInsideADynamicArray(): void $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 provideDatalistTypes(): array + { + return [ + 'datalist-strict' => ['datalist-strict'], + 'datalist-non-strict' => ['datalist-non-strict'], + ]; + } + public function testDatalistStrictAssociatesDatalist(): void { if ($this->skipForMissingDb()) { From 0e80fcf21d79f8c7983a9c29081d9e99b1897a8d Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 28 Jul 2026 10:34:08 +0200 Subject: [PATCH 218/221] DirectorProperty: add bool round-trip test Every other custom variable type had a create-and-reload test except bool, it only ever showed up as a value inside form fixtures. Filling that gap. --- .../Director/Objects/DirectorPropertyTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 0d239acd7..7f13b096f 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -45,6 +45,25 @@ public function testStringPropertyPersistsAndReloads(): void $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()) { From 9ea055da9ca3cd5e25372b3a63da9dbf1254d5e2 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 28 Jul 2026 10:34:42 +0200 Subject: [PATCH 219/221] doc: spell out fixed-array cannot nest inside itself The dynamic-array self-nesting rule was called out explicitly, but the same limit for fixed-array was only implied by the general ban on nesting container types. Made it explicit for symmetry. --- doc/12-Handling-custom-variables.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/12-Handling-custom-variables.md b/doc/12-Handling-custom-variables.md index 2169c6088..83add2bb5 100644 --- a/doc/12-Handling-custom-variables.md +++ b/doc/12-Handling-custom-variables.md @@ -67,10 +67,11 @@ types: > `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`. 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. +> `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 From 6a65c6af4c5a9d5613d47fc641218105b1835939 Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 28 Jul 2026 11:09:44 +0200 Subject: [PATCH 220/221] DirectorProperty: block sensitive child when switching type to an array or datalist The child side check only ran when the child got stored, so switching an existing dictionary to a dynamic-array or datalist skipped it and left a sensitive field exposed once rendered. --- library/Director/Objects/DirectorProperty.php | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/library/Director/Objects/DirectorProperty.php b/library/Director/Objects/DirectorProperty.php index ad9f68c50..618b3c5e2 100644 --- a/library/Director/Objects/DirectorProperty.php +++ b/library/Director/Objects/DirectorProperty.php @@ -445,20 +445,38 @@ public static function import(stdClass $plain, Db $db): static /** * @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, or a 'sensitive' - * item type is used under a dynamic-array or datalist + * 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; } - $valueType = $this->get('value_type'); - 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" From c54fbcab93852d2466af1a99bfd0660854c7d91c Mon Sep 17 00:00:00 2001 From: Ravi Srinivasa Date: Tue, 28 Jul 2026 11:09:44 +0200 Subject: [PATCH 221/221] Tests: cover switching a property's type when it has a sensitive child Checks both the array and datalist switch paths get rejected, and that switching type still works fine when there is nothing sensitive to protect. --- .../Director/Objects/DirectorPropertyTest.php | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/test/php/library/Director/Objects/DirectorPropertyTest.php b/test/php/library/Director/Objects/DirectorPropertyTest.php index 7f13b096f..6c3dd61e9 100644 --- a/test/php/library/Director/Objects/DirectorPropertyTest.php +++ b/test/php/library/Director/Objects/DirectorPropertyTest.php @@ -417,6 +417,84 @@ public function testSensitiveCannotBeNestedInsideADatalist(string $datalistType) $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 [